diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index eae8e260b3c2..316cf3509bf5 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -5,16 +5,13 @@ effort: high input: full_diff tools: - browse_code - - git_tools - - github_api_read_only - modify_pr include: - "apps/**/*.ts" - - "apps/**/*.tsx" - "packages/**/*.ts" - - "packages/**/*.tsx" - "infra/**/*.ts" - - "infra/**/*.tsx" +exclude: + - "**/*.test.ts" labels: - vouch:trusted requires: @@ -26,74 +23,50 @@ showToolCalls: true # Effect service review -Review changed TypeScript and directly affected call sites for the conventions below. Apply them when a pull request creates, moves, refactors, or consumes an Effect service. Do not demand unrelated repository-wide cleanup. Treat these instructions as authoritative when older code differs. +Review changed TypeScript for the conventions below. They apply when a pull request creates, moves, refactors, or consumes an Effect service. Review only the lines the PR changed; older code in the same file that predates these conventions is not a finding. Do not demand repository-wide cleanup. ## Imports and module namespaces -- Import Effect library modules from their subpaths as namespaces, for example `import * as Effect from "effect/Effect"` and `import * as Layer from "effect/Layer"`. Flag consolidated named imports from `"effect"` in touched Effect service code. -- At a service boundary, import the local service module as a namespace and use its public module shape: `WorkspacePaths.WorkspacePaths`, `WorkspacePaths.make`, and `WorkspacePaths.layer`. Flag aliases such as `import { layer as workspacePathsLayer }` that erase the module namespace. -- Namespace imports are not a blanket rule. Keep named imports for whole packages such as `@t3tools/contracts`, and for modules used only for a pure helper, error, schema, config value, or standalone type. Do not request `import type * as Contracts`. -- A package subpath that is itself a service module may use a namespace import when callers access its service/tag, `make`, or `layer` members. -- When a barrel exposes an entire service module, prefer `export * as TokenStore from "./tokenStore.ts"` so consumers can use `TokenStore.TokenStore` and `TokenStore.layer`. Do not individually rename `make` and `layer` exports to simulate a namespace. +- Import Effect modules from their subpaths as namespaces: `import * as Effect from "effect/Effect"`, `import * as Layer from "effect/Layer"`. Flag named imports from the bare `"effect"` package. +- At a service boundary, import the local service module as a namespace and use its public shape: `WorkspacePaths.WorkspacePaths`, `WorkspacePaths.make`, `WorkspacePaths.layer`. Flag aliases such as `import { layer as workspacePathsLayer }` that erase the namespace. +- Named imports stay correct for whole packages such as `@t3tools/contracts` and for modules used only for a pure helper, error, schema, config value, or type. Do not request `import type * as Contracts`. +- When a barrel exposes a whole service module, prefer `export * as TokenStore from "./tokenStore.ts"` over individually renamed `make` and `layer` exports. ## Service definition -- Use the canonical single-file order: imports, error/schema declarations, the `Context.Service` tag with its inline interface, `make`, then `layer`. -- Keep a service's schemas/errors, `Context.Service` tag, construction, and layer in one canonical module when they form one implementation. -- Define the service interface inline in the `Context.Service` declaration. Do not retain a standalone `FooShape` or `FooServiceShape` interface/type. -- Refer to the inferred service interface as `Foo["Service"]`, including in mechanically updated orchestration, MCP, tests, and integration harnesses. -- Export a real `make` when the module owns construction. Do not create `make = Effect.succeed(...)` solely to force `Layer.effect`. -- Export the canonical layer as `export const layer = Layer...`. `Layer.effect` is not required: use `Layer.succeed`, `Layer.scoped`, or another appropriate constructor when that matches the implementation. -- In a concrete implementation module already named for the implementation, use plain `make` and `layer` (for example `BunPtyAdapter.ts` and `NodePtyAdapter.ts`). -- Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. -- `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. +- One canonical module per service in this order: imports, error and schema declarations, the `Context.Service` tag with its interface inline, `make`, then `layer`. +- Define the interface inline in `Context.Service`. Do not add a standalone `FooShape` interface; refer to the inferred type as `Foo["Service"]`. +- Export a real `make` when the module owns construction. Do not write `make = Effect.succeed(...)` only to force `Layer.effect`; use `Layer.succeed`, `Layer.scoped`, or whichever constructor matches. +- Use plain `make` and `layer` in a module named for its implementation (`BunPtyAdapter.ts`). Keep implementation-specific names when one abstract port module holds several implementations (`makeCloudflaredRelayClient`, `layerCloudflared` in `RelayClient.ts`). `infra/relay/src/db.ts` may keep its inline `Layer.succeed(RelayDb, db)`. +- When a service moves, delete the old files and update every consumer, including orchestration, MCP, tests, and integration harnesses. Do not leave compatibility re-export shims. ## Dependency acquisition and runtime boundaries -- Production service construction must acquire Effect service dependencies from the environment with `yield* Foo.Foo`, and its `make`/`layer` types must expose those requirements. Flag factories or constructors that accept `Foo["Service"]` (or a plain object whose methods return `Effect`) when that value is an implementation dependency owned by the service. Passing service instances explicitly is acceptable in tests and integration harnesses; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. -- Do not hide dependencies in module globals, closures over singleton services, or `Layer.succeed` implementations that call runtime-backed or imperative APIs. Trace helpers used by a supposedly synchronous layer far enough to verify that asynchronous services are represented in the Effect environment. -- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at explicit application/framework boundaries such as React, native callback, CLI, or HTTP adapters. Flag their use in domain services, repositories, persistence implementations, and service constructors. A clearly named imperative adapter may bridge an Effect service into a Promise API, but it must not become a dependency of another Effect service. -- Do not create per-feature managed runtimes or Atom runtimes to smuggle the same owned resource into multiple consumers. Compose the resource once in an application-owned layer/runtime and provide its context to integration runtimes. -- When acquisition can fail but a caller must retain fallback behavior, keep the failure typed in Effect rather than bypassing the layer through an imperative runtime. Model unavailability in service operations or with an explicit optional-service layer so downstream recovery remains visible and testable. -- During review, search touched code and affected call sites for service-instance parameters, `Layer.succeed`, `ManagedRuntime.make`, and `.runPromise`/`.runPromiseExit`. Verify that each occurrence is a legitimate test seam, pure value injection, or application boundary—not fake dependency injection or a hidden runtime. +- Production service construction acquires its Effect dependencies from the environment with `yield* Foo.Foo`, and `make`/`layer` types expose those requirements. Flag a factory that takes `Foo["Service"]` (or an object of Effect-returning methods) as a parameter when that value is a service dependency. Passing service instances explicitly in tests is fine; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. +- Do not hide dependencies in module globals, closures over singleton services, or a `Layer.succeed` whose implementation calls runtime-backed or imperative APIs. +- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at application or framework boundaries: React, native callbacks, CLI, HTTP adapters. Flag them in domain services, repositories, persistence, and service constructors. A named imperative adapter may bridge an Effect service into a Promise API but must not become a dependency of another Effect service. +- Do not create per-feature managed runtimes or Atom runtimes to hand the same owned resource to several consumers. Compose the resource once in an application-owned layer and provide its context to integration runtimes. +- When acquisition can fail and callers need fallback behavior, keep the failure typed in Effect (an error in the service operation or an explicit optional-service layer) rather than bypassing the layer through an imperative runtime. -## Errors and predicates +## Errors -- Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. -- `Schema.Defect()` is not a substitute for modeling a generic error: its tag, fields, or both must identify the failure structurally, and its `message` must not merely stringify an opaque cause. A semantically precise error tag may preserve a real `cause` without inventing a redundant singleton field when no additional variable context exists; still retain any real path, resource, request, or entity context available at the wrapping site. -- Capture stable, serializable domain context such as the operation or stage, resource/path or entity identifier, and normalized category/status. Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error. Do not add a `detail` field that merely copies `cause.message` and then use it to construct the wrapper message. -- Keep direct error attributes and log annotations safe and bounded. Do not copy raw wire payloads, command arguments or output, signed URLs, credentials, query strings, fragments, selectors, or arbitrary defect text into `detail`, `reason`, `message`, or a parallel log payload. Preserve the exact underlying value only as `cause`; expose normalized categories plus lengths/counts and safe URL protocol/hostname diagnostics where useful. Logging a sanitized error must not reintroduce a removed legacy `detail` or serialized `cause` field beside it. -- When translating or wrapping a real failure, preserve the immediate underlying error itself as `cause` alongside the structural fields so the complete error chain and stack remain available. If every construction wraps a failure, `cause` should be required; make it optional only when the same error can legitimately originate without an underlying failure. -- At a translation boundary, pass through an already structured domain error when it is part of the declared target error channel. Wrap only unknown or genuinely lower-level failures. A static factory or mapper may perform this classification when it is reused and keeps the policy next to the target error type. -- Derive the wrapper's `message` exclusively from its stable structural attributes, never from `cause`, `cause.message`, or a stringified defect. Do not replace the immediate error with only `error.cause`, erase a structured upstream error into a string, or manufacture an `Error` merely to populate `cause`. Pure validation/domain errors created without an underlying failure do not need a cause. -- Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Choose one coherent model: use distinct error classes and omit the redundant discriminator when callers or messages treat the failures as genuinely different, or use one service-level error with a multi-value operation discriminator and a generic message derived from that operation when the failures share the same semantics. -- Treat an error message exposed through an HTTP/RPC response, persisted state, UI, or another caller-visible boundary as behavior. Preserve those messages during a structural refactor. Existing distinct caller-visible messages are evidence that the failures should normally remain distinct error tags without redundant singleton discriminators, rather than being collapsed into a generic operation error. -- Split semantically distinct failures into separate error classes when a `reason`, `kind`, `phase`, or similar discriminator is used to choose the user-facing message or drive caller control flow. A discriminator used only for internal diagnostics may remain a field. -- Use `Schema.Union` of error classes when a shared schema, predicate, or helper type is useful. -- Export direct schema predicates such as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a redundant function with the same signature. -- Do not introduce a large `switch` or lookup table in an error's `message` getter to model failures that deserve separate error classes. -- Catch statically known tagged failures with `Effect.catchTags({ ... })`, including when handling only one tag. Do not use `catchIf` with a schema predicate merely to recover one or more known `_tag` variants, and do not use `catchTag`. `Effect.catch` is appropriate when the entire error channel is intentionally handled; `catchIf` remains appropriate for genuinely structural predicates such as inspecting an underlying platform error code. -- For startup reconciliation that repairs multiple independent entities, preserve interruption rather than reducing it to a warning. Retry a transient per-entity repair before readiness, then isolate a persistent failure so one bad entity cannot abort global startup or prevent later entities from being repaired. Require tests for both the retry-success path and persistent-failure continuation. -- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`, including curried aliases used once with `mapError`. Construct the error at the failure boundary so its attributes and cause remain visible. Keep a mapper only when it performs real normalization, passes through existing domain errors, or adds reusable context/control flow. -- When a reusable error-to-error translation clearly belongs to the target error type, prefer a descriptive static factory on that error class over a detached production-side switch. Do not force a static method for one-off inline mappings. - -## File layout and migrations - -- When combining `domain/Services/Foo.ts` and `domain/Layers/Foo.ts`, hoist the result to `domain/Foo.ts`. -- Delete the old service/layer files. Do not leave compatibility re-export shims. Mechanically update every consumer, including orchestration, MCP, tests, and integration harnesses, to the canonical path. -- Do not flag genuinely separate implementation/adapter modules merely because they remain in an implementation-oriented directory. -- Avoid substantive orchestration or MCP redesign in service-cleanup PRs. Mechanical import, layer, and `Service["Service"]` updates are expected when required to remove obsolete paths or shapes. +- Define service failures with `Schema.TaggedErrorClass` and structured attributes: operation or stage, resource path or entity identifier, normalized category or status. Derive `message` from those attributes only. Never derive it from `cause`, `cause.message`, or a stringified defect, and do not add a `detail` field that copies `cause.message`. +- When wrapping a real failure, keep the immediate underlying error as `cause` so the chain and stack survive. Make `cause` required if every construction wraps a failure. Pure validation or domain errors created without an underlying failure need no cause. +- Keep attributes and log annotations safe and bounded: no raw wire payloads, command arguments or output, signed URLs, credentials, query strings, or arbitrary defect text. Preserve the exact value only as `cause`; expose normalized categories, lengths, counts, and safe URL protocol or hostname where useful. +- At a translation boundary, pass through an already structured domain error when it is part of the target error channel; wrap only unknown or lower-level failures. Map failures where the context is known instead of wrapping a whole multi-step pipeline in one generic error. +- Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Split into separate error classes when a discriminator drives caller control flow or the user-facing message; a discriminator used only for diagnostics may stay a field. Caller-visible messages exposed through HTTP, RPC, persisted state, or UI are behavior and must survive a structural refactor. +- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`. Construct the error at the failure boundary. Keep a mapper only when it performs real normalization, passes through domain errors, or adds reusable context; when such a mapper belongs to the target error type, prefer a static factory on that class. +- Export predicates directly as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a function with the same signature. +- Catch statically known tagged failures with `Effect.catchTags({ ... })`, including for a single tag; do not use `catchTag` or `catchIf` with a schema predicate for that. `Effect.catch` is fine when the whole error channel is handled; `catchIf` is fine for structural predicates such as a platform error code. ## Change discipline -- Preserve useful comments, invariants, and specification documentation while moving code. -- Require every new or broadened directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic to have an adjacent comment explaining why that diagnostic must be disabled there. The directive itself is not an explanation. Report a missing explanation as a concrete violation. -- Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. -- If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. -- Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, separate error classes for diagnostic-only fields, or new tests for import-only changes. +- Every new or broadened directive that disables a lint, type-checker, LSP, or static-analysis diagnostic needs an adjacent comment explaining why. The directive itself is not an explanation; a missing one is a concrete violation. +- If backend behavior changes, require focused tests that use test layers for external services only, never mocks of core business logic. Do not require new tests for mechanical refactors or import-only changes. +- Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, or separate error classes for diagnostic-only fields. ## Reporting -Report only concrete violations introduced or retained in the pull request's changed scope. Prefer precise inline comments on the smallest relevant line range and state the expected fix. A clear convention violation may fail the check. Do not fail for optional style preferences or unrelated legacy code. +Report only violations introduced by changed lines. Post each as a precise inline comment on the smallest relevant range and state the expected fix. A clear convention violation may fail the check; optional style preferences and untouched legacy code may not. -This check defaults to failure. When there are no findings, stop immediately and make the entire final response exactly `All clear` on one line. Do not add a title, explanation, punctuation, Markdown, JSON, or trailing analysis, and do not continue reasoning after deciding the review is clean. +When there are no findings, make the entire final response exactly `All clear` on one line with nothing else. diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 60bfe780ad62..436c0c08e4ed 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -663,15 +663,13 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Ref.update(state, withActiveRun(runId, f)); const snapshot = Ref.get(state).pipe( - Effect.map( - (current): DesktopBackendSnapshot => ({ - desiredRunning: current.desiredRunning, - ready: current.ready, - activePid: activePid(current.active), - restartAttempt: current.restartAttempt, - restartScheduled: Option.isSome(current.restartFiber), - }), - ), + Effect.map((current): DesktopBackendSnapshot => ({ + desiredRunning: current.desiredRunning, + ready: current.ready, + activePid: activePid(current.active), + restartAttempt: current.restartAttempt, + restartScheduled: Option.isSome(current.restartFiber), + })), ); const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 60e8761a8205..1da1083c089f 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -21,6 +21,7 @@ import { fetchSshEnvironmentDescriptor, fetchSshSessionState, issueSshWebSocketTicket, + resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; import { @@ -72,6 +73,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); + yield* ipc.handle(resolveSshHost); yield* ipc.handle(ensureSshEnvironment); yield* ipc.handle(disconnectSshEnvironment); yield* ipc.handle(fetchSshEnvironmentDescriptor); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 04751619e7ed..60396f45302f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -33,6 +33,7 @@ export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 9c9af2a4e2b9..cfb993d35cfa 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -117,6 +117,16 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({ }), }); +export const resolveSshHost = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_SSH_HOST_CHANNEL, + payload: Schema.String, + result: DesktopSshEnvironmentTargetSchema, + handler: Effect.fn("desktop.ipc.sshEnvironment.resolveHost")(function* (alias) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.resolveHost(alias); + }), +}); + export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ce0bd47259dd..5d71806e4f93 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -60,6 +60,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), + resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { diff --git a/apps/desktop/src/preview-pip-preload.ts b/apps/desktop/src/preview-pip-preload.ts index 384c4129774f..6771eba8aafe 100644 --- a/apps/desktop/src/preview-pip-preload.ts +++ b/apps/desktop/src/preview-pip-preload.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { contextBridge, ipcRenderer } from "electron"; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index b8afe29812a6..43a8f8056776 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { browserDefaultZoomFactor: 1.25, browserDefaultAppearance: "dark", browserRecordingFrameRate: 60, + browserLinkTarget: "app", browserAutoShowFloatingPreview: false, browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], browserDefaultProfileId: "work", @@ -29,6 +30,7 @@ const clientSettings: ClientSettings = { contextWindowMeterEnabled: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + diffLayout: "stacked", environmentIdentificationMode: "artwork", sidebarArtworkOverride: null, favorites: [], diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 31e84ae995ed..2c9ab0c03e48 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -5,6 +5,7 @@ import type { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as SshAuth from "@t3tools/ssh/auth"; +import { resolveSshTarget } from "@t3tools/ssh/command"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshCommandError, @@ -54,6 +55,9 @@ export class DesktopSshEnvironment extends Context.Service< readonly discoverHosts: (input?: { readonly homeDir?: string; }) => Effect.Effect; + readonly resolveHost: ( + alias: string, + ) => Effect.Effect; readonly ensureEnvironment: ( target: DesktopSshEnvironmentTarget, options?: { readonly issuePairingToken?: boolean }, @@ -136,6 +140,11 @@ export const make = Effect.gen(function* () { Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.discoverHosts"), ), + resolveHost: (alias) => + resolveSshTarget(alias.trim()).pipe( + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.resolveHost"), + ), ensureEnvironment: (target, ensureOptions) => manager .ensureEnvironment(target, ensureOptions) diff --git a/apps/marketing/public/pfps/JoshuaRileyDev.webp b/apps/marketing/public/pfps/JoshuaRileyDev.webp new file mode 100644 index 000000000000..437409176b11 Binary files /dev/null and b/apps/marketing/public/pfps/JoshuaRileyDev.webp differ diff --git a/apps/marketing/public/pfps/QuinnyPig.webp b/apps/marketing/public/pfps/QuinnyPig.webp new file mode 100644 index 000000000000..e12003c4ce64 Binary files /dev/null and b/apps/marketing/public/pfps/QuinnyPig.webp differ diff --git a/apps/marketing/public/pfps/RhysSullivan.webp b/apps/marketing/public/pfps/RhysSullivan.webp new file mode 100644 index 000000000000..a1fe04389a2d Binary files /dev/null and b/apps/marketing/public/pfps/RhysSullivan.webp differ diff --git a/apps/marketing/public/pfps/enunomaduro.webp b/apps/marketing/public/pfps/enunomaduro.webp new file mode 100644 index 000000000000..ce054dbfb655 Binary files /dev/null and b/apps/marketing/public/pfps/enunomaduro.webp differ diff --git a/apps/marketing/public/pfps/gnukeith.webp b/apps/marketing/public/pfps/gnukeith.webp deleted file mode 100644 index aa24d1996ffe..000000000000 Binary files a/apps/marketing/public/pfps/gnukeith.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/jetpackjoe_.webp b/apps/marketing/public/pfps/jetpackjoe_.webp deleted file mode 100644 index da415598564b..000000000000 Binary files a/apps/marketing/public/pfps/jetpackjoe_.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/peculiarnewbie.webp b/apps/marketing/public/pfps/peculiarnewbie.webp deleted file mode 100644 index 74be17008b24..000000000000 Binary files a/apps/marketing/public/pfps/peculiarnewbie.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/ryanrhughes.webp b/apps/marketing/public/pfps/ryanrhughes.webp new file mode 100644 index 000000000000..e949edaa0478 Binary files /dev/null and b/apps/marketing/public/pfps/ryanrhughes.webp differ diff --git a/apps/marketing/public/pfps/uwunetes.webp b/apps/marketing/public/pfps/uwunetes.webp deleted file mode 100644 index f15366aa66f9..000000000000 Binary files a/apps/marketing/public/pfps/uwunetes.webp and /dev/null differ diff --git a/apps/marketing/src/lib/tweets.ts b/apps/marketing/src/lib/tweets.ts index 7d6be71c5f15..604fbec91484 100644 --- a/apps/marketing/src/lib/tweets.ts +++ b/apps/marketing/src/lib/tweets.ts @@ -7,17 +7,24 @@ export type Tweet = { export const tweets = [ { - handle: "Shay_Benshabtay", + handle: "QuinnyPig", content: - "T3 code is not perfect, but it's damn good. Open and fun , I can make my own fork for my uses and use the great tooling with my own needs.", - link: "https://x.com/Shay_Benshabtay/status/2054668503857156326", + "Of course @theo is gonna favor his own product.\n\nBut I don’t have a horse in this race, so believe me when I say T3 code is freaking transformational.", + excerpt: + "...I don’t have a horse in this race, so believe me when I say T3 code is freaking transformational.", + link: "https://x.com/QuinnyPig/status/2089581739887014376", }, { - handle: "teja2495", + handle: "tannerlinsley", + content: "I would just use T3 Code at that point and contribute back.", + link: "https://x.com/tannerlinsley/status/2035067912659341399", + }, + { + handle: "ryanrhughes", content: - "I’ve completely switched to T3 Code for all my workflows. I just switch between different subscriptions and harnesses depending on what I need.", - excerpt: "I’ve completely switched to T3 Code for all my workflows.", - link: "https://x.com/teja2495/status/2052420254991581623", + "I think I've finally found the one AI harness to steal me away from the terminal — @t3dotcodes.", + excerpt: "I think I've finally found the one AI harness to steal me away from the terminal", + link: "https://x.com/ryanrhughes/status/2093051703616573769", }, { handle: "developedbyed", @@ -25,70 +32,72 @@ export const tweets = [ link: "https://x.com/developedbyed/status/2030627970532921605", }, { - handle: "tannerlinsley", + handle: "RhysSullivan", content: - "The minute T3 Code supports Claude Code, it could potentially become my daily driver. There's something special there.", - link: "https://x.com/tannerlinsley/status/2031102771529920966", + "T3 Code is worth giving a try if you haven’t yet\n\nIt takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile\n\nAlso is just nice to have all the official harnesses in one app", + excerpt: + "It takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile", + link: "https://x.com/RhysSullivan/status/2089750636686643493", }, { - handle: "aronprins", - content: - "I already loved T3 Code by @theo and @jullerino, but their Connections implementation is next level epic and for this they should both earn maximum repect 🔥🫡", - excerpt: "I already loved T3 Code, but their Connections implementation is next level epic.", - link: "https://x.com/aronprins/status/2045102518196183109", + handle: "DavidKPiano", + content: "It's like Claude Code if they didn't vibe-code the entire thing", + link: "https://x.com/DavidKPiano/status/2054682983504719930", }, { - handle: "BennettBuhner", + handle: "JoshuaRileyDev", content: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere! The app is great but the website is even greater, so instead of needing to SSH into a machine, T3 IS my SSH!", - excerpt: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere.", - link: "https://x.com/BennettBuhner/status/2054667115697754387", + "Well I was 2,000 miles away on holiday and able to do iOS development from just my phone, T3 Code’s remote feature is a magical experience", + excerpt: "I was 2,000 miles away on holiday and able to do iOS development from just my phone", + link: "https://x.com/JoshuaRileyDev/status/2094379365408616837", }, { - handle: "ex0t1clol", - content: "T3 Code is proof electron apps don't have to suck", - link: "https://x.com/ex0t1clol/status/2054666870008021197", + handle: "aronprins", + content: + "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!) 🤯\n\nHuge shoutout to @theo and @jullerino\n\nAmazing product fellers 🔥🙌", + excerpt: + "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!)", + link: "https://x.com/aronprins/status/2089761738358915267", }, { - handle: "Josikinz", + handle: "teja2495", content: - "T3 code is better because it’s like if the codex Mac app didn’t make my computer run like shit 😋", - link: "https://x.com/Josikinz/status/2030367951694745870", + "I’ve completely switched to T3 Code for all my workflows. I just switch between different subscriptions and harnesses depending on what I need.", + excerpt: "I’ve completely switched to T3 Code for all my workflows.", + link: "https://x.com/teja2495/status/2052420254991581623", }, { - handle: "jetpackjoe_", - content: "T3 code is pretty alright I guess", - link: "https://x.com/jetpackjoe_/status/2054666792933404959", + handle: "ryanrhughes", + content: + "Then there's the mobile app. All of my threads, on all of my environments, in my pocket. Fantastic.\n\nWhile traveling the other day, I had to use T3 Code alongside my old Claude Code + Herdr setup from 36,000ft on my phone.\n\nOn T3 Code, it felt like LAN. On the TUI, every keystroke took about 1sec to show up because it had to roundtrip to the device before I was able to actually see it. Brutal.", + excerpt: "...from 36,000ft on my phone. On T3 Code, it felt like LAN.", + link: "https://x.com/ryanrhughes/status/2093051708939059204", }, { - handle: "mil000", - content: "T3 code saved my relationship!", - link: "https://x.com/mil000/status/2030120041451246071", + handle: "ex0t1clol", + content: "T3 Code is proof electron apps don't have to suck", + link: "https://x.com/ex0t1clol/status/2054666870008021197", }, { - handle: "_winter_wonders", - content: "Heartbreaking: AI-hater has to admit T3 Code is really good.", - link: "https://x.com/_winter_wonders/status/2052350198764970434", + handle: "enunomaduro", + content: "So I tried @theo's T3 Code...", + link: "https://x.com/enunomaduro/status/2091915930141876495", }, { - handle: "kostyniuk00", + handle: "pocarles", content: - "I was not expecting a year ago, that my anti-AI colleagues would thank me a year later, for persuading them to try T3 Code Beta, that really helped them with organizing their workflows. \n\nFantastic product by @jullerino and @theo. Go try it out if you haven’t yet!", + "Only using Codex and T3 now.\n\nI thought all AI coding harnesses had roughly the same impact. Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", excerpt: - "My anti-AI colleagues thanked me for persuading them to try T3 Code Beta. Fantastic product.", - link: "https://x.com/kostyniuk00/status/2052041388179468521", + "Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", + link: "https://x.com/pocarles/status/2054673964274758046", }, { - handle: "gnukeith", + handle: "BennettBuhner", content: - "I tried T3 Code it actually fixed issues that Opus couldn’t solve in Claude Code + Theo has replied with “UwU” under my post. Dario has not.", - link: "https://x.com/gnukeith/status/2054670073579630730", - }, - { - handle: "peculiarnewbie", - content: "T3 Code is iOS and other harnesses are androids", - link: "https://x.com/peculiarnewbie/status/2054671685027233827", + "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere! The app is great but the website is even greater, so instead of needing to SSH into a machine, T3 IS my SSH!", + excerpt: + "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere.", + link: "https://x.com/BennettBuhner/status/2054667115697754387", }, { handle: "leodev", @@ -97,17 +106,10 @@ export const tweets = [ link: "https://x.com/leodev/status/2054679746353537042", }, { - handle: "pocarles", + handle: "Shay_Benshabtay", content: - "Only using Codex and T3 now.\n\nI thought all AI coding harnesses had roughly the same impact. Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - excerpt: - "Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - link: "https://x.com/pocarles/status/2054673964274758046", - }, - { - handle: "DavidKPiano", - content: "It's like Claude Code if they didn't vibe-code the entire thing", - link: "https://x.com/DavidKPiano/status/2054682983504719930", + "T3 code is not perfect, but it's damn good. Open and fun , I can make my own fork for my uses and use the great tooling with my own needs.", + link: "https://x.com/Shay_Benshabtay/status/2054668503857156326", }, { handle: "iamkaffe", @@ -115,9 +117,27 @@ export const tweets = [ link: "https://x.com/iamkaffe/status/2054675539311411280", }, { - handle: "uwunetes", + handle: "_winter_wonders", + content: "Heartbreaking: AI-hater has to admit T3 Code is really good.", + link: "https://x.com/_winter_wonders/status/2052350198764970434", + }, + { + handle: "kostyniuk00", content: - "claude code make me go *whine whine whine* and t3 code make me go woof woof awooooo!!!", - link: "https://x.com/uwunetes/status/2054683356022120640", + "I was not expecting a year ago, that my anti-AI colleagues would thank me a year later, for persuading them to try T3 Code Beta, that really helped them with organizing their workflows. \n\nFantastic product by @jullerino and @theo. Go try it out if you haven’t yet!", + excerpt: + "My anti-AI colleagues thanked me for persuading them to try T3 Code Beta. Fantastic product.", + link: "https://x.com/kostyniuk00/status/2052041388179468521", + }, + { + handle: "Josikinz", + content: + "T3 code is better because it’s like if the codex Mac app didn’t make my computer run like shit 😋", + link: "https://x.com/Josikinz/status/2030367951694745870", + }, + { + handle: "mil000", + content: "T3 code saved my relationship!", + link: "https://x.com/mil000/status/2030120041451246071", }, ] satisfies Tweet[]; diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 7f8f9c16afca..8ba542165f18 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -21,23 +21,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -81,23 +81,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -115,7 +115,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -206,23 +206,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -331,23 +331,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -365,7 +365,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -456,23 +456,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -581,23 +581,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -615,7 +615,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -706,23 +706,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -831,23 +831,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -865,7 +865,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -956,23 +956,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -1081,23 +1081,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -1115,7 +1115,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); @@ -1206,23 +1206,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); - --color-adaptive-neutral-100-900: oklch(97% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); - --color-adaptive-neutral-200-800: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); - --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); - --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); - --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); - --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); - --color-adaptive-neutral-400-500: oklch(70.8% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); - --color-adaptive-neutral-500-400: oklch(55.6% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(43.9% 0 0); - --color-adaptive-neutral-600-400: oklch(43.9% 0 0); - --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); @@ -1331,23 +1331,23 @@ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); - --color-adaptive-neutral-100-900: oklch(20.5% 0 0); - --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); - --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); - --color-adaptive-neutral-400-500: oklch(55.6% 0 0); - --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); - --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); - --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); - --color-adaptive-neutral-500-400: oklch(70.8% 0 0); - --color-adaptive-neutral-500-500: oklch(55.6% 0 0); - --color-adaptive-neutral-600-300: oklch(87% 0 0); - --color-adaptive-neutral-600-400: oklch(70.8% 0 0); - --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); @@ -1365,7 +1365,7 @@ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); - --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 3791d347c62b..f8eda69b6fb7 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -36,8 +36,7 @@ config.resolver = { new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`), ], extraNodeModules: { - // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread - ...(config.resolver?.extraNodeModules ?? {}), + ...config.resolver?.extraNodeModules, shiki: mobileShikiRoot, "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 2cd54b5c1ef2..36e87ee94158 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -9,10 +9,10 @@ const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ StyleSheet.create({}), ]); -const textDefaults: TextProps = { +const textDefaults = { allowFontScaling: true, selectable: true, -}; +} satisfies TextProps; const useTextAncestorContext = () => React.useContext(TextAncestorContext); @@ -28,7 +28,11 @@ export type ContextMenuActionEvent = { nativeEvent: { target: number; actionIdentifier: string }; }; -export type MarkdownTextPrimitiveProps = TextProps & { +/** + * `onTextLayout` is not offered: the native view reports plain line strings + * while the React Native Text fallback reports measured `TextLayoutLine`s. + */ +export type MarkdownTextPrimitiveProps = Omit & { uiTextView?: boolean; contextMenuConfig?: string; onContextMenuAction?: (event: ContextMenuActionEvent) => void; @@ -75,16 +79,14 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr }); if (!isAncestor) { + // Press handlers are delivered by the text runs; the container never sees them. + const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest; return ( {nativeChildren} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 0caa24c3404f..176585344167 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -1,31 +1,15 @@ import { + fileBasename, + formatFilePathPosition, inlineCodeFilePathCandidate, - isConventionalFilePosition, + normalizeMarkdownLinkDestination, + parseMarkdownFileLink, } from "@t3tools/client-runtime/markdown-links"; import { videoMimeType } from "@t3tools/shared/video"; import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; -const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; -const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = - /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = - /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const POSIX_FILE_ROOT_PREFIXES = [ - "/Users/", - "/home/", - "/tmp/", - "/var/", - "/etc/", - "/opt/", - "/mnt/", - "/Volumes/", - "/private/", - "/root/", -] as const; export type MarkdownLinkPresentation = | { @@ -246,112 +230,13 @@ const FILE_ICON_BY_EXTENSION: Readonly> = { zsh: "bash", }; -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -function normalizeDestination(value: string): string { - const trimmed = value.trim(); - return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; -} - /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ export function normalizeNativeMarkdownUrl(value: string): string { return value.startsWith("//") ? `https:${value}` : value; } -function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { - try { - const parsed = new URL(href); - if (parsed.protocol.toLowerCase() !== "file:") { - return null; - } - const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; - const rawPath = uncHostname - ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` - : parsed.pathname; - const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; - return { path, hash: parsed.hash }; - } catch { - return null; - } -} - -function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } { - const hashIndex = value.indexOf("#"); - const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; - const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; - const queryIndex = pathWithSearch.indexOf("?"); - return { - path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, - hash, - }; -} - -function splitFilePosition( - path: string, - hash: string, -): { readonly path: string; readonly line?: number; readonly column?: number } { - const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/); - const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i); - const match = suffixMatch ?? hashMatch; - if (!match?.[1]) { - return { path }; - } - - const line = Number.parseInt(match[1], 10); - const column = match[2] ? Number.parseInt(match[2], 10) : undefined; - const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path; - return { - path: pathWithoutPosition, - ...(line > 0 ? { line } : {}), - ...(column !== undefined && column > 0 ? { column } : {}), - }; -} - -function looksLikePosixFilesystemPath(path: string): boolean { - if (!path.startsWith("/")) { - return false; - } - if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { - return true; - } - if (POSITION_SUFFIX_PATTERN.test(path)) { - return true; - } - const basename = path.slice(path.lastIndexOf("/") + 1); - return /\.[A-Za-z0-9_-]+$/.test(basename); -} - -function looksLikeFilePath(value: string): boolean { - if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { - return true; - } - if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { - return true; - } - if (value.startsWith("/")) { - return looksLikePosixFilesystemPath(value); - } - if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { - return true; - } - if (isConventionalFilePosition(value)) return true; - return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); -} - -function fileLabel(value: string): string { - const normalized = value.replaceAll("\\", "/"); - const basename = normalized.slice(normalized.lastIndexOf("/") + 1); - return basename || normalized; -} - export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { - const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; @@ -367,7 +252,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { } export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { - const normalized = normalizeDestination(href); + const normalized = normalizeMarkdownLinkDestination(href); try { const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); if (parsed.protocol === "http:" || parsed.protocol === "https:") { @@ -381,31 +266,16 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese // Relative paths and non-URL link destinations are handled below. } - const source = normalized.toLowerCase().startsWith("file:") - ? fileUrlTarget(normalized) - : stripSearchAndHash(normalized); - const decodedSource = source - ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) } - : null; - const fileTarget = decodedSource - ? splitFilePosition(decodedSource.path, decodedSource.hash) - : null; - const targetWithPosition = fileTarget - ? `${fileTarget.path}${ - fileTarget.line - ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}` - : "" - }` - : null; - if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) { + const target = parseMarkdownFileLink(normalized); + if (target) { return { kind: "file", href: normalized, - icon: resolveMarkdownFileIcon(fileTarget.path), - label: fileLabel(targetWithPosition), - path: fileTarget.path, - ...(fileTarget.line ? { line: fileTarget.line } : {}), - ...(fileTarget.column ? { column: fileTarget.column } : {}), + icon: resolveMarkdownFileIcon(target.path), + label: fileBasename(formatFilePathPosition(target)), + path: target.path, + ...(target.line ? { line: target.line } : {}), + ...(target.column ? { column: target.column } : {}), }; } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift index 1a7009c3821d..45954053125b 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -144,6 +144,8 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, type = detectedType } else if CGPDFDocument(download as CFURL) != nil { type = .pdf + } else if URL(fileURLWithPath: title).pathExtension.lowercased() == "svg" { + type = .svg } else { throw URLError(.cannotDecodeContentData) } diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 0c2042218cda..5519f2817582 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -22,6 +22,7 @@ import IconBox from "@tabler/icons-react-native/IconBox"; import IconCamera from "@tabler/icons-react-native/IconCamera"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; +import IconCloud from "@tabler/icons-react-native/IconCloud"; import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; @@ -32,6 +33,7 @@ import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; +import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; import IconDots from "@tabler/icons-react-native/IconDots"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; import IconEdit from "@tabler/icons-react-native/IconEdit"; @@ -110,6 +112,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, + cloud: IconCloud, cube: IconBox, "chevron.down": IconChevronDown, "chevron.left": IconChevronLeft, @@ -129,9 +132,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "folder.fill": IconFolder, gearshape: IconSettings, "info.circle": IconInfoCircle, + laptopcomputer: IconDeviceLaptop, link: IconLink, "line.3.horizontal.decrease.circle": IconFilter, "line.3.horizontal.decrease.circle.fill": IconFilterFilled, + // Tabler has no Apple desktops; the closest silhouettes stand in on Android. + macmini: IconServer, + macstudio: IconDeviceDesktop, magnifyingglass: IconSearch, paintbrush: IconPalette, "person.crop.circle": IconUserCircle, @@ -178,6 +185,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record = { close: IconX, construction: IconHammer, content_copy: IconCopy, + desktop_windows: IconDeviceDesktop, edit: IconEdit, error: IconAlertCircle, folder: IconFolder, diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 16f0d422af78..b5d86c44109d 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,12 +1,12 @@ import { SymbolView } from "../components/AppSymbol"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useRef, useState } from "react"; -import { Alert, Image, Pressable, ScrollView, View } from "react-native"; +import { useMemo } from "react"; +import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { MediaActionsSource } from "../lib/mediaActions"; import { PresentationSource } from "./NativePresentation"; import type { FilePreviewSource } from "./FilePreviewModal"; import { isPdfFile } from "../lib/filePreview"; @@ -175,46 +175,16 @@ function ComposerVideoAttachment(props: { const { attachment } = props; const sourceIdentifier = `draft:${attachment.id}`; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; - const shareRef = useRef(null); - const [sharing, setSharing] = useState(false); - useEffect( - () => () => { - shareRef.current?.abort(); - shareRef.current = null; - }, - [], + const actionsSource = useMemo( + () => ({ + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + sourceIdentifier, + attachment, + }), + [attachment, sourceIdentifier], ); - const onShare = () => { - if (shareRef.current) return; - const controller = new AbortController(); - shareRef.current = controller; - setSharing(true); - void (async () => { - const preview = await loadLocalAttachmentPreview(attachment, controller.signal); - if (!preview) return; - try { - await preview.share(controller.signal, sourceIdentifier); - } finally { - preview.dispose(); - } - })() - .catch((error: unknown) => { - if (!controller.signal.aborted) { - Alert.alert( - "Could not share video", - error instanceof Error ? error.message : "Try again.", - ); - } - }) - .finally(() => { - if (shareRef.current === controller) { - shareRef.current = null; - setSharing(false); - } - }); - }; - return ( props.onPressVideo(attachment, sourceIdentifier)} - onShare={onShare} - disabled={sharing} + actionsSource={actionsSource} style={style} /> ); diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx new file mode 100644 index 000000000000..46fbbd814fdf --- /dev/null +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -0,0 +1,39 @@ +import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import type { SFSymbol } from "expo-symbols"; + +import { SymbolView } from "./AppSymbol"; + +const SYMBOL_BY_KIND: Record = { + server: "server.rack", + cloud: "cloud", + desktop: "desktopcomputer", + laptop: "laptopcomputer", + "mac-mini": "macmini", + "mac-studio": "macstudio", +}; + +export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { + server: "Server", + cloud: "Cloud VM", + desktop: "Desktop", + laptop: "Laptop", + "mac-mini": "Mac mini", + "mac-studio": "Mac Studio", +}; + +/** The glyph an environment wears in lists; SF Symbols on iOS, Tabler on Android. */ +export function EnvironmentMachineSymbol(props: { + readonly kind: EnvironmentMachineKind; + readonly size: number; + readonly tintColorClassName: string; +}) { + return ( + + ); +} diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index c2f5a6d72cc7..af943b8be9a2 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; -import { MediaImagePreview } from "./MediaImagePreview"; const NativeControls = requireNativeModule<{ presentFile( @@ -47,9 +46,5 @@ export function FilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; }) { - return props.source.kind === "image" && props.source.actionsSource ? ( - - ) : ( - - ); + return ; } diff --git a/apps/mobile/src/components/MediaActionsMenu.tsx b/apps/mobile/src/components/MediaActionsMenu.tsx index a4b44e85258f..f54e1378ba80 100644 --- a/apps/mobile/src/components/MediaActionsMenu.tsx +++ b/apps/mobile/src/components/MediaActionsMenu.tsx @@ -1,6 +1,6 @@ import { MenuView } from "@react-native-menu/menu"; import type { ReactElement } from "react"; -import { Platform, View, type PressableProps } from "react-native"; +import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; import type { useMediaActions } from "../lib/mediaActions"; import { SymbolView } from "./AppSymbol"; @@ -10,6 +10,7 @@ export function MediaActionsMenu(props: { readonly media: ReturnType; readonly inModal?: boolean; readonly children?: ReactElement; + readonly style?: StyleProp; }) { if (props.media.actions.length === 0) return props.children ?? null; // Android's normal anchored menu lives in the app-root portal, behind native modals. @@ -18,6 +19,7 @@ export function MediaActionsMenu(props: { return ( ({ id, diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 5bdc9140ddc9..03e317c296af 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -42,7 +42,7 @@ function ImagePreviewHeader() { ); } -/** Chat and workspace media retain source actions on both platforms; other files use native previews. */ +/** Android keeps media actions in its in-app image viewer. iOS uses Quick Look. */ export function MediaImagePreview(props: MediaImagePreviewProps) { return ( diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index a065f75e1396..0e3d8416affa 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -101,8 +101,8 @@ interface MediaVideoPlayerProps { readonly thumbnailVisible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; + readonly autoPlay?: boolean; readonly paused?: boolean; - readonly onExpand?: () => void; readonly actionsSource?: MediaActionsSource; } @@ -122,62 +122,51 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { ) : ( - setPlaybackUri(props.uri)} - className="flex-1 items-center justify-center gap-2 px-4" - > - {!props.unavailable ? ( - - ) : null} - {props.unavailable ? ( - Video unavailable - ) : props.uri === null ? ( - - ) : ( - <> + + 0 ? "Touch and hold for media actions" : undefined + } + accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} + // Stays pressable so the long-press menu still opens on a failed or unsigned tile. + onPress={() => { + if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); + }} + className="flex-1 items-center justify-center px-4" + > + {!props.unavailable ? ( + + ) : null} + {props.unavailable ? ( + Video unavailable + ) : props.uri === null ? ( + + ) : ( + )} + {props.uri !== null && !props.unavailable ? ( {props.name} - - )} - + ) : null} + + )} - {props.onExpand ? ( - { - setPlaybackUri(null); - props.onExpand?.(); - }} - className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" - > - Expand - - ) : null} - {props.actionsSource ? ( - - - - ) : null} ); } diff --git a/apps/mobile/src/components/MediaVideoPreviewModal.tsx b/apps/mobile/src/components/MediaVideoPreviewModal.tsx deleted file mode 100644 index 6c231194701c..000000000000 --- a/apps/mobile/src/components/MediaVideoPreviewModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect } from "react"; -import { Keyboard, Modal, Pressable, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { useMediaActions } from "../lib/mediaActions"; -import { MediaActionsMenu } from "./MediaActionsMenu"; -import { - mediaVideoPreviewUri, - mediaVideoThumbnailKey, - type MediaVideoPreviewSource, -} from "../lib/videoPreviewSource"; -import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; -import { usePreparedConnection } from "../state/session"; -import { AppText } from "./AppText"; -import { SymbolView } from "./AppSymbol"; -import { MediaVideoPlayer } from "./MediaVideoPlayer"; -import { MediaSourceCaption } from "./MediaSourceCaption"; - -/** Media files stream in place. A client-side copy is made only for an explicit share. */ -export function MediaVideoPreviewModal(props: { - readonly source: MediaVideoPreviewSource; - readonly onRequestClose: () => void; -}) { - const { source } = props; - const insets = useSafeAreaInsets(); - const environmentId = "environmentId" in source ? source.environmentId : null; - const connection = usePreparedConnection(environmentId); - const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); - const refreshAssetUrl = useRefreshAssetUrl( - environmentId, - "resource" in source ? source.resource : null, - ); - const resolvePlaybackUri = - "resource" in source - ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) - : undefined; - const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); - const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); - const unavailable = - uri === null && - environmentId !== null && - (connection._tag === "None" || asset._tag === "Failure"); - - useEffect(() => Keyboard.dismiss(), []); - return ( - - - - - {source.name} - - - - - - - - - - - {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} - - - - - ); -} diff --git a/apps/mobile/src/components/VideoAttachmentMenu.tsx b/apps/mobile/src/components/VideoAttachmentMenu.tsx deleted file mode 100644 index 301d6503a508..000000000000 --- a/apps/mobile/src/components/VideoAttachmentMenu.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ReactElement } from "react"; -import { Platform, type PressableProps } from "react-native"; - -import { ControlPillMenu } from "./ControlPill"; -import { PresentationSource } from "./NativePresentation"; - -export function VideoAttachmentMenu(props: { - readonly sourceIdentifier: string; - readonly onOpen: () => void; - readonly onShare?: () => void; - readonly disabled?: boolean; - readonly children: ReactElement; -}) { - return ( - { - if (!props.disabled) props.onOpen(); - }} - accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} - onAccessibilityAction={({ nativeEvent }) => { - if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); - }} - > - {Platform.OS === "ios" && props.onShare ? ( - { - if (nativeEvent.event === "share") props.onShare?.(); - }} - > - {props.children} - - ) : ( - props.children - )} - - ); -} diff --git a/apps/mobile/src/components/VideoAttachmentTile.tsx b/apps/mobile/src/components/VideoAttachmentTile.tsx index 6f582ac5f005..f8c7e2c1cbdc 100644 --- a/apps/mobile/src/components/VideoAttachmentTile.tsx +++ b/apps/mobile/src/components/VideoAttachmentTile.tsx @@ -2,65 +2,79 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react import { cn } from "../lib/cn"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; -import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { PresentationSource } from "./NativePresentation"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; export function VideoAttachmentTile(props: { readonly name: string; readonly sourceIdentifier: string; readonly thumbnailSource: string | DraftComposerFileAttachment | null; + readonly actionsSource?: MediaActionsSource; readonly compact?: boolean; readonly onPress: (sourceIdentifier: string) => void; - readonly onShare?: () => void; readonly disabled?: boolean; readonly className?: string; readonly style?: StyleProp; }) { + const mediaActions = useMediaActions(props.disabled ? undefined : props.actionsSource); + const hasActions = mediaActions.actions.length > 0; return ( - props.onPress(props.sourceIdentifier)} - onShare={props.onShare} - disabled={props.disabled} + { + if (!props.disabled) props.onPress(props.sourceIdentifier); + }} + accessibilityActions={mediaActions.actions.map(({ id, title }) => ({ + name: id, + label: title, + }))} + onAccessibilityAction={({ nativeEvent }) => { + if (props.disabled) return; + mediaActions.actions.find(({ id }) => id === nativeEvent.actionName)?.run(); + }} > - props.onPress(props.sourceIdentifier)} - className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} - style={props.style} - > - - + props.onPress(props.sourceIdentifier)} + className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} + style={props.style} > - - - {!props.compact ? ( - - - {props.name} - + + + - ) : null} - - + {!props.compact ? ( + + + {props.name} + + + ) : null} + + + ); } diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 88a1b5191dd0..ae69e0ead89a 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -1,14 +1,12 @@ import { useIsFocused } from "@react-navigation/native"; -import { videoMimeType } from "@t3tools/shared/video"; import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId, useState } from "react"; import { Alert, Keyboard } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import { useAssetUrlState } from "../state/assets"; +import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; -import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; @@ -23,27 +21,28 @@ const NativeControls = requireNativeModule<{ }>("T3NativeControls"); function NativeVideoPreview(props: { - readonly source: AttachmentVideoPreviewSource; + readonly source: VideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; - const { attachment } = source; + const localAttachment = source.type === "local" ? source.attachment : null; const identifier = useId(); const onRequestClose = useEffectEvent(props.onRequestClose); - const environmentId = source.type === "remote" ? source.environmentId : null; + const environmentId = + source.type === "media" && "environmentId" in source ? source.environmentId : null; + const resource = source.type === "media" && "resource" in source ? source.resource : null; const preparedConnection = usePreparedConnection(environmentId); - const mimeType = videoMimeType(attachment) ?? attachment.mimeType; - const assetUrl = useAssetUrlState( - environmentId, - source.type === "remote" - ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } - : null, - ); - const [playbackUrl, setPlaybackUrl] = useState(() => - assetUrl._tag === "Success" ? assetUrl.url : null, - ); + const assetUrl = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource)); + const name = source.type === "media" ? source.name : source.attachment.name; + // The first minted URL is kept so a background refresh does not restart playback. + const resolvedUrl = + source.type === "media" + ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null) + : null; + const [playbackUrl, setPlaybackUrl] = useState(resolvedUrl); const loadError = - source.type === "remote" && playbackUrl === null + resource !== null && playbackUrl === null ? preparedConnection._tag === "None" ? "Reconnect to this environment and open the video again." : assetUrl._tag === "Failure" @@ -53,8 +52,8 @@ function NativeVideoPreview(props: { useEffect(() => Keyboard.dismiss(), []); useEffect(() => { - if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); - }, [playbackUrl, assetUrl]); + if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); + }, [playbackUrl, resolvedUrl]); useEffect(() => { if (!loadError) return; Alert.alert("Could not open video", loadError); @@ -62,21 +61,21 @@ function NativeVideoPreview(props: { }, [loadError]); useEffect(() => { - if (source.type === "remote" && playbackUrl === null) return; + if (localAttachment === null && playbackUrl === null) return; const controller = new AbortController(); let ready = false; void (async () => { const file = - source.type === "local" - ? await loadLocalAttachmentPreview(source.attachment, controller.signal) + localAttachment !== null + ? await loadLocalAttachmentPreview(localAttachment, controller.signal) : null; - if (source.type === "local" && !file) return; + if (localAttachment !== null && !file) return; try { if (controller.signal.aborted) return; ready = true; await NativeControls.presentVideo( file?.uri ?? playbackUrl!, - attachment.name, + name, source.sourceIdentifier ?? "", identifier, ); @@ -87,10 +86,12 @@ function NativeVideoPreview(props: { } })().catch((error: unknown) => { if (controller.signal.aborted) return; + // AVKit gives no retry, so re-mint now; the cached URL may simply have expired. + if (ready) void refreshAssetUrl(); Alert.alert( "Could not open video", ready - ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." + ? "This video couldn't be loaded or played. Check the connection, or touch and hold the video to save or share the original." : error instanceof Error ? error.message : "Could not load this video.", @@ -101,7 +102,7 @@ function NativeVideoPreview(props: { controller.abort(); void NativeControls.dismissVideo(identifier).catch(() => undefined); }; - }, [source, attachment.name, playbackUrl, identifier]); + }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]); return null; } @@ -118,8 +119,5 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource]); if (!props.source || !isFocused) return null; - if (props.source.type === "media") { - return ; - } return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index cc56b3952b75..2a585b5e1a30 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,190 +1,139 @@ import { useIsFocused } from "@react-navigation/native"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEvent } from "expo"; -import { useVideoPlayer, VideoView } from "expo-video"; import { useEffect, useRef, useState } from "react"; -import { - ActivityIndicator, - AppState, - Keyboard, - Modal, - Pressable, - StyleSheet, - View, -} from "react-native"; +import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - downloadAttachmentForPreview, - type AttachmentPreviewFile, -} from "../lib/attachmentDownload"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; -import { useAssetUrlState } from "../state/assets"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type LocalVideoPreviewSource, + type MediaVideoPreviewSource, + type VideoPreviewSource, +} from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; -import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; +import { SymbolView } from "./AppSymbol"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; +import { MediaVideoPlayer } from "./MediaVideoPlayer"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; -function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { - const player = useVideoPlayer(props.file.uri, (player) => { - player.staysActiveInBackground = false; - if (AppState.currentState === "active") player.play(); - }); - const { status } = useEvent(player, "statusChange", { status: player.status }); - const shareControllerRef = useRef(null); - const [sharing, setSharing] = useState(false); - const [shareError, setShareError] = useState(null); - - useEffect( - () => () => { - shareControllerRef.current?.abort(); - shareControllerRef.current = null; - }, - [], - ); +interface PlaybackState { + readonly uri: string | null; + readonly resolvePlaybackUri?: () => Promise; + readonly unavailable: boolean; + readonly error: string | null; + readonly actionsSource: MediaActionsSource | undefined; +} - const onShare = () => { - if (shareControllerRef.current) return; - player.pause(); - const controller = new AbortController(); - shareControllerRef.current = controller; - setSharing(true); - setShareError(null); - void props.file - .share(controller.signal) - .catch((error: unknown) => { - if (!controller.signal.aborted) { - setShareError(error instanceof Error ? error.message : "Could not share this video."); - } - }) - .finally(() => { - if (shareControllerRef.current === controller) { - shareControllerRef.current = null; - setSharing(false); - } - }); +function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { + const environmentId = "environmentId" in source ? source.environmentId : null; + const resource = "resource" in source ? source.resource : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return { + uri, + ...(resource !== null + ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) } + : {}), + unavailable: + uri === null && + environmentId !== null && + (connection._tag === "None" || asset._tag === "Failure"), + error: null, + actionsSource: source.actionsSource, }; - - return ( - <> - - {status === "error" ? ( - - This video couldn't be played on this device. You can save or share the original file. - - ) : ( - <> - - {status === "loading" ? ( - - ) : null} - - )} - - - - {sharing ? "Opening share sheet..." : "Save or share video"} - - - {shareError ? ( - - {shareError} - - ) : null} - - ); } -function OpenVideoPreviewModal(props: { - readonly source: AttachmentVideoPreviewSource; - readonly onRequestClose: () => void; -}) { - const { source } = props; +function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { const { attachment } = source; - const insets = useSafeAreaInsets(); - const environmentId = source.type === "remote" ? source.environmentId : null; - const preparedConnection = usePreparedConnection(environmentId); - const fileUri = source.type === "local" ? source.attachment.fileUri : null; - const mimeType = videoMimeType(attachment) ?? attachment.mimeType; - const assetUrl = useAssetUrlState( - environmentId, - source.type === "remote" - ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } - : null, - ); - const [downloadUrl, setDownloadUrl] = useState(null); - const [file, setFile] = useState(null); - const [failure, setFailure] = useState(null); - - useEffect(() => Keyboard.dismiss(), []); - useEffect(() => { - if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { - setDownloadUrl(assetUrl.url); - } - }, [environmentId, downloadUrl, assetUrl]); - + const [uri, setUri] = useState(null); + const [error, setError] = useState(null); + // Only a different file needs a new lease; a metadata update on the same + // draft must not dispose the file Android is still playing. + const attachmentRef = useRef(attachment); + attachmentRef.current = attachment; + const { id: attachmentId, fileUri } = attachment; useEffect(() => { - if (source.type === "remote" && downloadUrl === null) return; + setUri(null); + setError(null); const controller = new AbortController(); - let preview: AttachmentPreviewFile | null = null; - setFile(null); - setFailure(null); - const loading = - source.type === "local" - ? loadLocalAttachmentPreview(source.attachment, controller.signal) - : downloadAttachmentForPreview({ - url: downloadUrl!, - attachment: { name: attachment.name, mimeType }, - signal: controller.signal, - }); + const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal); void loading.then( - (loaded) => { - if (controller.signal.aborted) { - loaded?.dispose(); - return; - } - preview = loaded; - setFile(loaded); + (file) => { + if (file === null) return; + if (controller.signal.aborted) file.dispose(); + else setUri(file.uri); }, - (error: unknown) => { + (cause: unknown) => { if (!controller.signal.aborted) { - setFailure(error instanceof Error ? error.message : "Could not load this video."); + setError(cause instanceof Error ? cause.message : "Could not load this video."); } }, ); return () => { controller.abort(); - preview?.dispose(); + void loading.then( + (file) => file?.dispose(), + () => undefined, + ); }; - }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); + }, [attachmentId, fileUri]); + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + return { + uri, + unavailable: error !== null, + error, + actionsSource: uri === null ? undefined : { name: attachment.name, mimeType, uri }, + }; +} + +function MediaPreviewModal(props: { + readonly source: MediaVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + return ( + + ); +} + +function LocalPreviewModal(props: { + readonly source: LocalVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + return ( + + ); +} - const loadError = - failure ?? - (environmentId !== null && downloadUrl === null - ? preparedConnection._tag === "None" - ? "This environment is disconnected. Reconnect and open the video again." - : assetUrl._tag === "Failure" - ? "Could not load this video. Check the connection to this environment and try again." - : null - : null); +function OpenVideoPreviewModal(props: { + readonly name: string; + readonly thumbnailKey: string; + readonly playback: PlaybackState; + readonly onRequestClose: () => void; +}) { + const { playback } = props; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); + useEffect(() => Keyboard.dismiss(), []); return ( - {attachment.name} + {props.name} + - {file ? ( - - ) : ( + + {playback.uri === null && !playback.unavailable ? ( - {loadError ? ( - - {loadError} - - ) : ( - <> - - Loading video... - - )} + + Loading video... + ) : ( + )} + {playback.error ? ( + + {playback.error} + + ) : null} + + + {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} + + ); @@ -243,12 +211,17 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource, props.onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; - if (source.type === "media") { - return ; - } - const key = - source.type === "local" - ? `local:${source.attachment.id}:${source.attachment.fileUri}` - : `remote:${source.environmentId}:${source.attachment.id}`; - return ; + return source.type === "local" ? ( + + ) : ( + + ); } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 5b61d302a767..c2d5b6cf65ab 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -3,7 +3,11 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { LegendList } from "@legendapp/list/react-native"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + type EnvironmentId, + type EnvironmentMachineKind, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -24,10 +28,12 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { useServerConfigs } from "../../state/entities"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { createNativeMailSearchToolbarItem, @@ -45,6 +51,7 @@ type ArchivedThreadListItem = readonly kind: "project"; readonly key: string; readonly environmentLabel: string | null; + readonly environmentMachine: EnvironmentMachineKind; readonly project: EnvironmentProject; } | { @@ -360,6 +367,7 @@ function ArchivedThreadsHeader(props: { function ProjectGroupLabel(props: { readonly environmentLabel: string | null; + readonly environmentMachine: EnvironmentMachineKind; readonly project: EnvironmentProject; }) { return ( @@ -378,9 +386,16 @@ function ProjectGroupLabel(props: { {props.project.title} {props.environmentLabel ? ( - - {props.environmentLabel} - + + + + {props.environmentLabel} + + ) : null} ); @@ -517,6 +532,7 @@ export function ArchivedThreadsScreen(props: { ), [props.environments], ); + const serverConfigs = useServerConfigs(); const listItems = useMemo>(() => { const items: ArchivedThreadListItem[] = []; for (const group of props.groups) { @@ -525,6 +541,9 @@ export function ArchivedThreadsScreen(props: { kind: "project", key: `${group.key}:project`, environmentLabel, + environmentMachine: resolveEnvironmentMachineKind( + serverConfigs.get(group.project.environmentId) ?? null, + ), project: group.project, }); @@ -540,7 +559,7 @@ export function ArchivedThreadsScreen(props: { }); } return items; - }, [environmentLabelsById, props.groups]); + }, [environmentLabelsById, props.groups, serverConfigs]); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current && openSwipeableRef.current !== methods) { openSwipeableRef.current.close(); @@ -559,7 +578,11 @@ export function ArchivedThreadsScreen(props: { if (item.kind === "project") { return ( - + ); } diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 4c840636c9fc..163e5fcf16f1 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -4,7 +4,12 @@ import { connectionStatusText, type EnvironmentConnectionPhase, } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + type EnvironmentId, + type EnvironmentMachineKind, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useCallback, useState } from "react"; import { ActivityIndicator, @@ -15,10 +20,12 @@ import { } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { serverEnvironment } from "../../state/server"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; @@ -205,6 +212,9 @@ function ConnectedCloudEnvironmentRow(props: { readonly onDisconnect: () => void; readonly onToggleError: () => void; }) { + const serverConfig = useAtomValue( + serverEnvironment.configValueAtom(props.environment.environmentId), + ); return ( { if (enabled) { props.onConnect(); @@ -268,6 +279,8 @@ function CloudEnvironmentRowShell(props: { readonly disabled?: boolean; readonly errorExpanded: boolean; readonly label: string; + /** Absent for environments the relay lists but this device has not connected to. */ + readonly machine?: EnvironmentMachineKind; readonly onToggleError: () => void; readonly onValueChange: (enabled: boolean) => void; readonly statusText?: string; @@ -323,6 +336,13 @@ function CloudEnvironmentRowShell(props: { + {props.machine ? ( + + ) : null} - - {props.environment.environmentLabel} - + + + + {props.environment.environmentLabel} + + {props.environment.displayUrl} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ef7771aafb97..b87070a2d623 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -668,7 +668,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { id: action.id, title: action.title, icon: - action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), + action.id === "save" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), inline: false, onPress: action.run, })) diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 3e6afae6f84e..ae6987f4c1c8 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,11 +1,8 @@ -import { useAtomValue } from "@effect/atom-react"; import { useId, useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; -import { workspaceFileImageAtom } from "./workspace-file-image-cache"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { PresentationSource } from "../../components/NativePresentation"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; @@ -27,86 +24,52 @@ function ResolvedWorkspaceFileImagePreview(props: { return ( - - setPreview({ - kind: "image", - uri: props.uri, - name: props.accessibilityLabel, - sourceIdentifier, - actionsSource: props.actionsSource, - }) - } - > - - setLoadError(null)} - onError={(event) => { - setLoadError(event.nativeEvent.error || "The image could not be rendered."); - }} - /> - - + + 0 ? "Touch and hold for media actions" : undefined + } + disabled={loadError !== null} + className="flex-1 p-4 active:bg-subtle-strong" + onPress={() => + setPreview({ + kind: "image", + uri: props.uri, + name: props.accessibilityLabel, + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + > + + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + + + {loadError !== null ? ( - + ) : null} - - - - - setPreview(null)} /> ); } -function CachedWorkspaceFileImagePreview(props: { - readonly accessibilityLabel: string; - readonly uri: string; - readonly actionsSource?: MediaActionsSource; -}) { - const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); - const imageResult = useAtomValue(imageAtom); - - if (AsyncResult.isFailure(imageResult)) { - return ( - - - - ); - } - - if (!AsyncResult.isSuccess(imageResult)) { - return ( - - - Loading image... - - ); - } - - return ( - - ); -} - export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string | null; @@ -124,7 +87,7 @@ export function WorkspaceFileImagePreview(props: { } return ( - Promise; readonly unavailable: boolean; }) { - const [preview, setPreview] = useState(null); const uri = props.uri; if (props.unavailable) { @@ -37,11 +34,7 @@ export function WorkspaceFileVideoPreview(props: { name={props.name} thumbnailKey={props.thumbnailKey} actionsSource={props.source?.actionsSource} - onExpand={ - uri === null || props.source === null ? undefined : () => setPreview(props.source) - } /> - setPreview(null)} /> ); } diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts deleted file mode 100644 index 4acb67361a8a..000000000000 --- a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; - -describe("workspaceFileImageAtom", () => { - it("reuses a prefetched image across route remounts", async () => { - const prefetch = vi.fn(async () => true); - const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch }); - const registry = AtomRegistry.make({ timeoutResolution: 1 }); - const first = imageAtom("https://example.test/image.png"); - const firstUnmount = registry.mount(first); - - await vi.waitFor(() => { - expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); - }); - firstUnmount(); - - const remounted = imageAtom("https://example.test/image.png"); - const secondUnmount = registry.mount(remounted); - - expect(remounted).toBe(first); - expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true); - expect(prefetch).toHaveBeenCalledTimes(1); - - secondUnmount(); - registry.dispose(); - }); - - it("prefetches different asset URLs independently", async () => { - const prefetch = vi.fn(async () => true); - const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch }); - const registry = AtomRegistry.make(); - const first = imageAtom("https://example.test/first.png"); - const second = imageAtom("https://example.test/second.png"); - const firstUnmount = registry.mount(first); - const secondUnmount = registry.mount(second); - - await vi.waitFor(() => { - expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); - expect(AsyncResult.isSuccess(registry.get(second))).toBe(true); - }); - expect(prefetch).toHaveBeenCalledTimes(2); - - firstUnmount(); - secondUnmount(); - registry.dispose(); - }); - - it("exposes prefetch failures", async () => { - const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); - const registry = AtomRegistry.make(); - const atom = imageAtom("https://example.test/missing.png"); - const unmount = registry.mount(atom); - - await vi.waitFor(() => { - expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); - }); - - unmount(); - registry.dispose(); - }); -}); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts deleted file mode 100644 index 3f58f65b46c9..000000000000 --- a/apps/mobile/src/features/files/workspace-file-image-cache.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import { Atom } from "effect/unstable/reactivity"; - -const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; - -type ImagePrefetch = (uri: string) => Promise; - -class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} - -export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ - readonly cause?: unknown; - readonly uri: string; -}> {} - -async function prefetchWithNativeImage(uri: string): Promise { - const { Image } = await import("react-native"); - return Image.prefetch(uri); -} - -export function createWorkspaceFileImageAtomFamily(options?: { - readonly idleTtlMs?: number; - readonly prefetch?: ImagePrefetch; -}) { - const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; - const prefetch = options?.prefetch ?? prefetchWithNativeImage; - const family = Atom.family((key: WorkspaceImageCacheKey) => - Atom.make( - Effect.tryPromise({ - try: async () => { - const cached = await prefetch(key.uri); - if (!cached) { - throw new WorkspaceImagePrefetchError({ uri: key.uri }); - } - return key.uri; - }, - catch: (cause) => - cause instanceof WorkspaceImagePrefetchError - ? cause - : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), - }), - ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), - ); - - return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); -} - -export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily(); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d7b76873d6ce..b4343e95b031 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,10 +12,11 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, +import { + type EnvironmentId, + resolveEnvironmentMachineKind, + type SidebarProjectGroupingMode, + type SidebarThreadSortOrder, } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -616,6 +617,16 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const machineByEnvironmentId = useMemo( + () => + new Map( + [...serverConfigs].map( + ([environmentId, config]) => + [environmentId, resolveEnvironmentMachineKind(config)] as const, + ), + ), + [serverConfigs], + ); // Canonical arranged pinned order (reorder-capable threads only) for the // Move up/down position flags. Computed from all shells, not the rendered // list, so search/scope filtering never disables or misdirects a move. @@ -744,6 +755,7 @@ export function HomeScreen(props: HomeScreenProps) { ?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)} showPendingDivider={item.showPendingDivider} showTrailingDivider={showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} @@ -801,6 +813,7 @@ export function HomeScreen(props: HomeScreenProps) { ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ environmentId: thread.environmentId, @@ -851,6 +864,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + machineByEnvironmentId, pinReorderEnvironmentIds, projectByKey, projectCwdByKey, @@ -942,6 +956,9 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById[item.pendingTask.message.environmentId] ?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get( + item.pendingTask.message.environmentId, + )} isLast={item.isLast} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} @@ -956,6 +973,7 @@ export function HomeScreen(props: HomeScreenProps) { environmentLabel={ props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -994,6 +1012,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleRegenerateThreadTitle, + machineByEnvironmentId, projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 4079ea5591ea..fe22a1e6e071 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -31,7 +31,13 @@ import { inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; -import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + type EnvironmentId, + type EnvironmentMachineKind, + ProjectId, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -49,6 +55,7 @@ import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; import { uuidv4 } from "../../lib/uuid"; @@ -65,6 +72,7 @@ interface EnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; readonly platform: string; + readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; @@ -352,6 +360,7 @@ function useEnvironmentOptions(): ReadonlyArray { environmentId: connection.environmentId, label: connection.environmentLabel, platform: platformFromOs(config?.environment.platform.os ?? null), + machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, @@ -493,11 +502,10 @@ export function AddProjectSourceScreen(props: { readonly incomingShareId?: strin }) } icon={ - } selected={environment.environmentId === selectedEnvironment?.environmentId} diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 3480340f4093..bf66574717eb 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -1,4 +1,5 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; @@ -6,11 +7,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { clearClientCacheAtom, clientCacheSummaryAtom, type EnvironmentClientCacheSummary, } from "../../state/client-cache-state"; +import { useServerConfigs } from "../../state/entities"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsSection } from "./components/SettingsSection"; @@ -20,6 +23,7 @@ export function SettingsClientStorageRouteScreen() { const clearResult = useAtomValue(clearClientCacheAtom); const clearCache = useAtomSet(clearClientCacheAtom); const { savedConnectionsById } = useSavedRemoteConnections(); + const serverConfigs = useServerConfigs(); const isClearing = clearResult.waiting; const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null; const environmentSummaries = useMemo( @@ -106,6 +110,9 @@ export function SettingsClientStorageRouteScreen() { savedConnectionsById[environment.environmentId]?.environmentLabel ?? environment.environmentId } + machine={resolveEnvironmentMachineKind( + serverConfigs.get(environment.environmentId) ?? null, + )} disabled={isClearing} first={index === 0} onClear={() => confirmClearEnvironment(environment)} @@ -169,6 +176,7 @@ export function SettingsClientStorageRouteScreen() { function CacheEnvironmentRow(props: { readonly environment: EnvironmentClientCacheSummary; readonly environmentLabel: string; + readonly machine: EnvironmentMachineKind; readonly disabled: boolean; readonly first: boolean; readonly onClear: () => void; @@ -181,13 +189,7 @@ function CacheEnvironmentRow(props: { : "border-t border-border flex-row items-center gap-3 p-4" } > - + {props.environmentLabel} diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 41bab785ac3d..9b5d8e113f85 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -12,7 +12,6 @@ import Animated, { import type { ComponentProps } from "react"; import { AppText as Text } from "../../../../components/AppText"; -import { useUniwindTheme } from "../../../../lib/useUniwindTheme"; type SymbolName = ComponentProps["name"]; @@ -36,10 +35,6 @@ export function FontSizeSliderRow(props: { readonly value: number; readonly onChange: (value: number) => void; }) { - const theme = useUniwindTheme(); - const trackColor = theme["--color-secondary-border"]; - const fillColor = theme["--color-primary"]; - const latest = useRef(props); latest.current = props; @@ -172,12 +167,12 @@ export function FontSizeSliderRow(props: { }} > ({ - // The encoded href doubles as the launcher id: URI-encoding makes the - // env/thread join unambiguous (a plain `-` join lets different pairs - // collide and overwrite each other's launcher slots). - id: `thread:${threadShortcutHref(thread)}`, - title: threadShortcutLabel(thread), - icon: SHORTCUT_ICON, - params: { href: threadShortcutHref(thread) }, - }), - ), + ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map((thread): Action => ({ + // The encoded href doubles as the launcher id: URI-encoding makes the + // env/thread join unambiguous (a plain `-` join lets different pairs + // collide and overwrite each other's launcher slots). + id: `thread:${threadShortcutHref(thread)}`, + title: threadShortcutLabel(thread), + icon: SHORTCUT_ICON, + params: { href: threadShortcutHref(thread) }, + })), ]; } diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index f370401e8ecc..a51e084efc9e 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -948,16 +948,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, ], }, - ...terminalMenuSessions.map( - (session): MenuAction => ({ - id: `terminal-session:${session.terminalId}`, - title: session.displayLabel, - subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] - .filter(Boolean) - .join(" · "), - state: session.terminalId === terminalId ? ("on" as const) : undefined, - }), - ), + ...terminalMenuSessions.map((session): MenuAction => ({ + id: `terminal-session:${session.terminalId}`, + title: session.displayLabel, + subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] + .filter(Boolean) + .join(" · "), + state: session.terminalId === terminalId ? ("on" as const) : undefined, + })), { id: "terminal-new", title: "Open new terminal", diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 411598db08d7..96bd7438057a 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -1,4 +1,5 @@ import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { LegendList } from "@legendapp/list/react-native"; import { isAtomCommandInterrupted, @@ -21,11 +22,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; -import { useFontFamily } from "../../lib/useFontFamily"; -import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useServerConfigs } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; import { @@ -37,7 +38,7 @@ import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; function SelectionRow(props: { - readonly icon?: "arrow.triangle.branch" | "desktopcomputer"; + readonly icon?: "arrow.triangle.branch" | ReactNode; readonly onPress: () => void; readonly disabled?: boolean; readonly selected: boolean; @@ -58,14 +59,16 @@ function SelectionRow(props: { onPress={props.onPress} style={{ opacity: props.disabled ? 0.45 : 1 }} > - {props.icon ? ( + {props.icon === "arrow.triangle.branch" ? ( - ) : null} + ) : ( + (props.icon ?? null) + )} {props.title} @@ -147,6 +150,7 @@ export function NewTaskEnvironmentPickerRouteScreen() { const flow = useNewTaskFlow(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const serverConfigs = useServerConfigs(); return ( @@ -172,7 +176,15 @@ export function NewTaskEnvironmentPickerRouteScreen() { {flow.environments.map((environment, index) => ( + } isLast={index === flow.environments.length - 1} onPress={() => { void Haptics.selectionAsync(); @@ -193,8 +205,6 @@ export function NewTaskBranchPickerRouteScreen() { const flow = useNewTaskFlow(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const foregroundColor = useUniwindTheme()["--color-foreground"]; - const fontFamily = useFontFamily("regular"); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const [switchingBranchName, setSwitchingBranchName] = useState(null); const selectingBranchNameRef = useRef(null); @@ -417,11 +427,10 @@ export function NewTaskBranchPickerRouteScreen() { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 9db040414402..e0ca8fee3249 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -24,7 +24,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { @@ -36,6 +39,7 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { composerAttachmentUploadBlockReason, composerAttachmentUploadsAtom, @@ -1129,7 +1133,13 @@ export function NewTaskDraftScreen(props: { accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} chevronDirection="right" disabled={isComposerInteractionLocked || voiceInput.isBusy} - icon="desktopcomputer" + iconNode={ + + } label={`on ${selectedEnvironmentLabel}`} maxWidth={260} onPress={ diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1b7cb5f373ec..4d198ce4ae3a 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -105,6 +105,7 @@ import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { + attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource, @@ -251,14 +252,21 @@ function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; + readonly mimeType: string; readonly className: string; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); - const uri = useAssetUrl(props.environmentId, { - _tag: "attachment", - attachmentId: props.attachmentId, - }); + const resource = useMemo( + () => ({ + _tag: "attachment" as const, + attachmentId: props.attachmentId, + fileName: props.name, + mimeType: props.mimeType, + }), + [props.attachmentId, props.name, props.mimeType], + ); + const uri = useAssetUrl(props.environmentId, resource); if (uri === null) { return ( @@ -274,7 +282,20 @@ function MessageAttachmentImage(props: { accessibilityRole="imagebutton" accessibilityLabel={`Open ${props.name}`} onPress={() => - props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) + // The viewer mints its own URL from the resource so the image survives a refresh. + props.onPressPreview({ + kind: "image", + environmentId: props.environmentId, + resource, + name: props.name, + sourceIdentifier, + actionsSource: { + name: props.name, + mimeType: props.mimeType, + environmentId: props.environmentId, + resource, + }, + }) } > @@ -389,14 +410,18 @@ function MessageAttachmentFile(props: { }; if (videoType !== null) { + const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; return ( props.onPressVideo(attachment, sourceIdentifier)} - onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} className="my-1 rounded-2xl" style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} /> @@ -520,62 +545,60 @@ function ThreadMarkdownImageView(props: { style={{ alignSelf: "stretch", gap: 6 }} > {props.uri === null || failed ? ( - - {failed ? ( - Image unavailable - ) : ( - - )} - {props.actionsSource ? ( - - - - ) : null} - + + 0 ? "Touch and hold for media actions" : undefined + } + className="items-center justify-center rounded-[10px] bg-md-code-bg" + style={frameStyle} + > + {failed ? ( + Image unavailable + ) : ( + + )} + + ) : ( - - - - props.onPressPreview({ - kind: "image", - uri: props.uri!, - name: props.alt ?? "Image", - sourceIdentifier, - actionsSource: props.actionsSource, - }) - } - style={{ alignSelf: "flex-start" }} + + 0 ? "Touch and hold for media actions" : undefined + } + onPress={() => + // Quick Look picks the viewer from the name's extension, so it needs the + // file name rather than the alt text. + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.actionsSource?.name ?? props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + - - setFailedUri(props.uri)} - /> - - - - {props.actionsSource ? ( - - + setFailedUri(props.uri)} + /> - ) : null} - + + )} {props.alt ? ( @@ -658,10 +681,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { return {props.children}; } -function ThreadMarkdownVideo(props: { - readonly source: MediaVideoPreviewSource; - readonly onExpand: (source: MediaVideoPreviewSource) => void; -}) { +function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { const { source } = props; const visible = useContext(ThreadMediaVisibleContext); const thumbnailKey = mediaVideoThumbnailKey(source); @@ -688,7 +708,6 @@ function ThreadMarkdownVideo(props: { thumbnailVisible={visible} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} - onExpand={() => props.onExpand(source)} /> ); } @@ -1482,6 +1501,7 @@ function renderFeedEntry( readonly markdownStyles: MarkdownStyleSets; readonly reviewCommentColors: ReviewCommentColors; readonly reviewCommentBubbleWidth: number; + readonly themeAppearance: "light" | "dark"; readonly userBubbleMaxWidth: number; }, ) { @@ -1519,12 +1539,16 @@ function renderFeedEntry( if (entry.type === "work-toggle") { return ( @@ -1656,6 +1681,7 @@ function renderFeedEntry( environmentId={props.environmentId} attachmentId={attachment.id} name={attachment.name} + mimeType={attachment.mimeType} className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" onPressPreview={props.onPressPreview} /> @@ -1695,12 +1721,14 @@ function renderFeedEntry( // Anchors/details live in ThreadFeed and survive this group-only remount. key={`${entry.id}:${props.workRowSizing.textSizeKey}`} activities={entry.activities} + environmentId={props.environmentId} anchorKey={entry.id} copiedRowId={props.copiedRowId} expandedRows={props.expandedWorkRows} rowSizing={props.workRowSizing} scrollPositions={props.workGroupScrollPositions} iconSubtleColor={iconSubtleColor} + themeAppearance={props.themeAppearance} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} renderImage={props.renderViewedImage} @@ -1980,6 +2008,7 @@ function ThreadFeedPlaceholder(props: { export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const navigation = useNavigation(); + const { themeAppearance } = useAppearancePreferences(); const copyFeedbackTimeoutRef = useRef | null>(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); @@ -2218,7 +2247,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedVideo((current) => current ?? source)} /> ); } @@ -2299,6 +2327,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { iconSubtleColor, markdownStyles, reviewCommentColors, + themeAppearance, userBubbleColor, viewportWidth, }), @@ -2309,6 +2338,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { iconSubtleColor, markdownStyles, reviewCommentColors, + themeAppearance, userBubbleColor, viewportWidth, ], @@ -2667,12 +2697,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (attachment: ChatFileAttachment, sourceIdentifier: string) => { setExpandedVideo( (current) => - current ?? { - type: "remote", - environmentId: props.environmentId, - attachment, - sourceIdentifier, - }, + current ?? + attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), ); }, [props.environmentId], @@ -2738,6 +2764,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + themeAppearance, userBubbleMaxWidth, skills: props.skills, onUseArtifactTemplate: props.onUseArtifactTemplate, @@ -2758,6 +2785,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + themeAppearance, userBubbleMaxWidth, onCopyWorkRow, markdownLinkHandlers, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 4c4a91054eb6..c06eb2f6ce95 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,7 +9,7 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; @@ -442,6 +442,16 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const machineByEnvironmentId = useMemo( + () => + new Map( + [...serverConfigs].map( + ([environmentId, config]) => + [environmentId, resolveEnvironmentMachineKind(config)] as const, + ), + ), + [serverConfigs], + ); // Canonical arranged pinned order for Move up/down flags — computed from // all shells so search/scope filtering never disables a valid move. const arrangedPinnedKeys = useMemo(() => { @@ -820,6 +830,9 @@ function ThreadNavigationSidebarPane( ?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get( + item.pendingTask.message.environmentId, + )} pane="sidebar" showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} @@ -854,6 +867,7 @@ function ThreadNavigationSidebarPane( ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ environmentId: thread.environmentId, @@ -957,6 +971,9 @@ function ThreadNavigationSidebarPane( savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get( + item.pendingTask.message.environmentId, + )} isLast={item.isLast} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} @@ -971,6 +988,7 @@ function ThreadNavigationSidebarPane( environmentLabel={ savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -1018,6 +1036,7 @@ function ThreadNavigationSidebarPane( handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + machineByEnvironmentId, movePinnedThread, openPendingTask, pinReorderEnvironmentIds, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index df10e585aaad..dc5beea14d24 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -4,6 +4,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import type { EnvironmentMachineKind } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; @@ -14,6 +15,7 @@ import Svg, { Circle, Path } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; @@ -268,14 +270,12 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly variant: ThreadListVariant; readonly pendingTask: PendingNewTask; readonly environmentLabel: string | null; + readonly environmentMachine?: EnvironmentMachineKind; readonly isLast: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const compact = props.variant === "compact"; - const theme = useUniwindTheme(); - const separatorColor = theme["--color-separator"]; - const pressedBackgroundColor = theme["--color-subtle"]; const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const timestamp = relativeTime(pendingTask.message.createdAt); @@ -305,6 +305,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"} type="monochrome" /> + {props.environmentLabel && props.environmentMachine ? ( + + ) : null} onSelectPendingTask(pendingTask)} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > - - + + {pendingTask.title} @@ -366,16 +359,16 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" + className="active:bg-subtle" onPress={() => onSelectPendingTask(pendingTask)} - style={({ pressed }) => ({ - backgroundColor: pressed ? pressedBackgroundColor : "transparent", + style={{ borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, - })} + }} > @@ -416,6 +409,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly variant: ThreadListVariant; readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; + readonly environmentMachine?: EnvironmentMachineKind; readonly projectCwd: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -444,7 +438,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const [hovered, setHovered] = useRecyclingState(false); const theme = useUniwindTheme(); - const separatorColor = theme["--color-separator"]; const screenColor = theme["--color-screen"]; const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; @@ -524,6 +517,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {subtitleParts.length > 0 ? ( <> + {props.environmentLabel && props.environmentMachine ? ( + + ) : null} { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > - - + + {thread.title} 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 2403eb02544b..38c7ca04a758 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,6 +3,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; @@ -13,6 +14,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; @@ -201,6 +203,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly environmentLabel: string | null; + /** Drawn beside the label; ignored while the label is null. */ + readonly environmentMachine?: EnvironmentMachineKind; readonly pane?: "screen" | "sidebar"; /** Draws the "Pending" divider above the first queued row. */ readonly showPendingDivider: boolean; @@ -210,9 +214,6 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const theme = useUniwindTheme(); - const drawerColor = theme["--color-drawer"]; - const pressedBackgroundColor = theme["--color-subtle"]; const sidebarPane = props.pane === "sidebar"; const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; @@ -249,17 +250,26 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {pendingTask.title} {branch || props.environmentLabel ? ( - - {branch ? ( - - {branch} - - ) : null} - {branch && props.environmentLabel ? " · " : null} - {props.environmentLabel ? ( - {props.environmentLabel} + + + {branch ? ( + + {branch} + + ) : null} + {branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + {props.environmentLabel} + ) : null} + + {props.environmentLabel && props.environmentMachine ? ( + ) : null} - + ) : null} ); @@ -278,15 +288,15 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props accessibilityHint="Opens the queued task for editing" accessibilityLabel={pendingTask.title} accessibilityRole="button" + className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined} onPress={() => onSelectPendingTask(pendingTask)} style={ sidebarPane - ? ({ pressed }) => ({ - backgroundColor: pressed ? pressedBackgroundColor : drawerColor, + ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, paddingHorizontal: 12, paddingVertical: 10, - }) + } : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } > @@ -327,6 +337,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { the web sidebar's remote-environment cloud icon, but as text since phones have no hover tooltips. */ readonly environmentLabel: string | null; + /** Drawn after the label so the machine reads at a glance; ignored while + the label is null. */ + readonly environmentMachine?: EnvironmentMachineKind; /** Hosting surface. "screen" (default) renders the compact Home idiom: flat edge-to-edge rows on the screen background with inset hairlines. "sidebar" renders the iPad split-view idiom: rounded rows blending @@ -767,6 +780,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : ( )} + {status !== "failed" && props.environmentLabel && props.environmentMachine ? ( + + ) : null} {pr ? ( ["onTextLayout"]; readonly showIcon: boolean; + readonly themeAppearance?: "light" | "dark"; + readonly toolIcon?: ToolActivityIcon; }) { return ( - {props.showIcon ? ( + {props.showIcon && props.toolIcon && props.environmentId ? ( + + ) : props.showIcon ? ( setAvailableWidth(event.nativeEvent.layout.width)} > setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} /> {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( @@ -272,11 +293,14 @@ export function ShimmeringWorkContent(props: { > @@ -307,10 +331,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { return { ios: "sparkles", android: "auto_awesome" }; case "alert": return { ios: "exclamationmark.triangle", android: "error" }; + case "browser": + return { ios: "safari", android: "public" }; case "check": return { ios: "checkmark", android: "check" }; case "command": return { ios: "terminal", android: "terminal" }; + case "computer": + return { ios: "desktopcomputer", android: "desktop_windows" }; case "edit": return { ios: "square.and.pencil", android: "edit" }; case "eye": @@ -369,11 +397,13 @@ export function collapsedWorkLogHeight(activities: ReadonlyArray; readonly anchorKey: string; + readonly environmentId: EnvironmentId; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly iconSubtleColor: ColorValue; + readonly themeAppearance: "light" | "dark"; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string, anchorKey: string) => void; readonly renderImage: MarkdownImageRenderer; @@ -388,20 +418,24 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { anchorKey={props.anchorKey} copied={props.copiedRowId === row.id} expanded={props.expandedRows[row.id] ?? false} + environmentId={props.environmentId} iconSubtleColor={props.iconSubtleColor} onCopyRow={props.onCopyRow} onToggleRow={props.onToggleRow} renderImage={props.renderImage} + themeAppearance={props.themeAppearance} /> ), [ props.anchorKey, props.copiedRowId, props.expandedRows, + props.environmentId, props.iconSubtleColor, props.onCopyRow, props.onToggleRow, props.renderImage, + props.themeAppearance, ], ); @@ -638,7 +672,10 @@ function ThreadWorkGroupList(props: { scrollsToTop={false} bounces={false} keyboardShouldPersistTaps="handled" - style={StyleSheet.absoluteFill} + // MaskedView bridges through a native host whose absolute-fill bounds + // can lag behind a resize. Keep the list's viewport at the group's + // current height when expanding details or appending calls. + style={[StyleSheet.absoluteFill, { height }]} /> ); @@ -669,7 +706,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; const failed = row.status === "failure"; - const icon = toolPresentation?.icon ?? (failed ? "xmark" : workRowSymbolName(row.icon)); + const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon; + const hasSpecialToolIcon = + toolPresentation !== null || row.workEntry.toolSurface !== undefined || toolIcon !== undefined; + const icon = + toolPresentation?.icon ?? + (failed && !hasSpecialToolIcon ? "xmark" : workRowSymbolName(row.icon)); return ( {row.live ? ( ) : ( <> - + {failed && !hasSpecialToolIcon ? ( + + ) : ( + + )} ) : null} - {failed && toolPresentation ? ( + {failed && hasSpecialToolIcon ? ( ; readonly expanded: boolean; readonly hiddenCount: number; @@ -793,6 +849,9 @@ export function ThreadWorkGroupToggle(props: { readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; readonly summaryToolIcon?: "browser" | "t3-code"; + readonly themeAppearance: "light" | "dark"; + readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; + readonly toolIcon?: ToolActivityIcon; readonly hasFailure: boolean; readonly shimmer: boolean; readonly onToggle: () => void; @@ -800,7 +859,11 @@ export function ThreadWorkGroupToggle(props: { const accessibilityLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; - const icon = props.summaryToolIcon ?? toolGroupSummarySymbolName(props.summaryKind); + const icon = + props.summaryToolIcon ?? + (props.toolSurface + ? workRowSymbolName(props.toolSurface) + : toolGroupSummarySymbolName(props.summaryKind)); return ( @@ -820,15 +883,24 @@ export function ThreadWorkGroupToggle(props: { {props.shimmer ? ( ) : ( <> - + ; + } + if (props.icon._tag === "website") { + const source = toolActivityFaviconUrl(props.icon, props.themeAppearance, 32); + return source ? ( + + ) : ( + + ); + } + if (props.icon._tag === "themed-logo") { + const source = + props.themeAppearance === "dark" + ? (props.icon.logoUrlDark ?? props.icon.logoUrl) + : props.icon.logoUrl; + return ( + + ); + } + return ( + + ); +} + +function NativeAppToolActivityIcon(props: { + readonly environmentId: EnvironmentId; + readonly app: Extract["app"]; + readonly fallback: WorkContentIcon; + readonly color: ColorValue; + readonly themeAppearance: "light" | "dark"; +}) { + const source = useAssetUrl(props.environmentId, { + _tag: "native-app-icon", + app: props.app, + }); + return source ? ( + + ) : ( + + ); +} + +function ToolActivityImage(props: { + readonly source: string; + readonly fallback: WorkContentIcon; + readonly color: ColorValue; + readonly themeAppearance: "light" | "dark"; +}) { + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); + return ( + + {!loaded || failed ? : null} + {!failed ? ( + + setLoaded(true)} + onError={() => setFailed(true)} + /> + + ) : null} + + ); +} + function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { switch (kind) { case "read": diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f17b46b1d18b..2b44851f9309 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -255,25 +255,21 @@ export function buildThreadListV2ListItems(input: { readonly settledShelfHeaderIndex?: number | null; readonly snoozeLabelNow?: string; }): ThreadListV2ListItem[] { - const threadItems = input.items.map( - (item): ThreadListV2ListItem => ({ - type: "v2-thread", - key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, - item, - snoozeWakeLabelText: - item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined - ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) - : undefined, - }), - ); - const pendingItems = input.pendingTasks.map( - (pendingTask, index): ThreadListV2ListItem => ({ - type: "v2-pending", - key: `v2-pending:${pendingTask.message.messageId}`, - pendingTask, - showPendingDivider: index === 0, - }), - ); + const threadItems = input.items.map((item): ThreadListV2ListItem => ({ + type: "v2-thread", + key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, + item, + snoozeWakeLabelText: + item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined + ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) + : undefined, + })); + const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ + type: "v2-pending", + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + showPendingDivider: index === 0, + })); const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; const settledCount = input.settledCount ?? 0; diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts index d45993364721..8ffab4e045b4 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -14,9 +14,9 @@ export function useThreadListV2ShelfPreferences() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); const snoozedShelfExpanded = - loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true; + loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; const settledShelfExpanded = - !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false; + loaded && preferencesResult.value.threadListSettledShelfExpanded === true; const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); const settledShelfExpandedRef = useRef(settledShelfExpanded); snoozedShelfExpandedRef.current = snoozedShelfExpanded; @@ -26,13 +26,13 @@ export function useThreadListV2ShelfPreferences() { if (!loaded) return; const expanded = !snoozedShelfExpandedRef.current; snoozedShelfExpandedRef.current = expanded; - savePreferences({ threadListV2SnoozedShelfExpanded: expanded }); + savePreferences({ threadListSnoozedShelfExpanded: expanded }); }, [loaded, savePreferences]); const toggleSettledShelf = useCallback(() => { if (!loaded) return; const expanded = !settledShelfExpandedRef.current; settledShelfExpandedRef.current = expanded; - savePreferences({ threadListV2SettledShelfExpanded: expanded }); + savePreferences({ threadListSettledShelfExpanded: expanded }); }, [loaded, savePreferences]); return { diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts index 77834630dc2f..1d7320eb0670 100644 --- a/apps/mobile/src/lib/markdownMedia.test.ts +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -61,6 +61,17 @@ describe("resolveMarkdownMediaPreview", () => { }); }); + it("serves a linked T3 attachment file in place like any other host path", () => { + const path = "/home/demo/.t3/userdata/attachments/11111111-1111-4111-8111-111111111111-mp4.mp4"; + expect(resolveMarkdownMediaPreview(path, input)).toMatchObject({ + kind: "video", + source: { + resource: { _tag: "media-file", threadId: input.threadId, path }, + actionsSource: { resource: { _tag: "media-file", path } }, + }, + }); + }); + it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { expect( resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts index 2196c2f22f2b..b1b616f0e442 100644 --- a/apps/mobile/src/lib/markdownMedia.ts +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -1,15 +1,6 @@ -import { - classifyMarkdownImageSource, - markdownImageSourceFragment, -} from "@t3tools/client-runtime/markdown-images"; +import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; -import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; -import { - mediaFileReference, - mediaReferenceFileName, - mediaUrlReference, -} from "@t3tools/client-runtime/media-reference"; import type { FilePreviewSource } from "../components/FilePreviewModal"; import type { MediaVideoPreviewSource } from "./videoPreviewSource"; @@ -29,61 +20,30 @@ export function resolveMarkdownMediaPreview( | { readonly kind: "image"; readonly source: FilePreviewSource } | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } | null { - const classified = classifyMarkdownImageSource(href, input.workspaceRoot); - if (classified._tag === "Blocked") return null; - const path = - classified._tag === "WorkspaceFile" - ? classified.path.replace(/:\d+(?::\d+)?$/, "") - : classified.uri.split(/[?#]/, 1)[0]!; - const basename = path.split(/[\\/]/).at(-1) ?? ""; - const extensionIndex = basename.lastIndexOf("."); - // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. - const detectedMimeType = - classified._tag === "Direct" - ? mediaMimeType(classified.uri) - : extensionIndex < 0 - ? null - : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); - const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); - if (mimeType === null) return null; - const kind = mimeType.startsWith("video/") ? "video" : "image"; - const reference = - classified._tag === "Direct" - ? mediaUrlReference(classified.uri) - : mediaFileReference(path, input.workspaceRoot); - const name = - (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); - const srcFragment = markdownImageSourceFragment(href); + const media = resolveMediaSource(href, input); + if (media === null || media.access === "unavailable") return null; + const { kind, name, mimeType, reference, srcFragment } = media; + const target = - classified._tag === "Direct" - ? { uri: normalizeNativeMarkdownUrl(classified.uri) } + media.access === "direct" + ? { uri: normalizeNativeMarkdownUrl(media.uri) } : { environmentId: input.environmentId, - resource: { - _tag: "media-file" as const, - threadId: input.threadId, - path, - }, + resource: media.resource, ...(srcFragment ? { srcFragment } : {}), }; const actionsSource: MediaActionsSource = - classified._tag === "Direct" - ? { reference, uri: classified.uri, name, mimeType } + media.access === "direct" + ? { reference, uri: media.uri, name, mimeType } : { reference, environmentId: input.environmentId, threadId: input.threadId, - resource: { _tag: "media-file", threadId: input.threadId, path }, + resource: media.resource, name, mimeType, }; return kind === "video" - ? { - kind, - source: { type: "media", name, mimeType, ...target, actionsSource }, - } - : { - kind, - source: { kind, name, ...target, actionsSource }, - }; + ? { kind, source: { type: "media", name, mimeType, ...target, actionsSource } } + : { kind, source: { kind, name, ...target, actionsSource } }; } diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts index 14dc2de7bd21..c37ed76c2bf5 100644 --- a/apps/mobile/src/lib/mediaActions.ts +++ b/apps/mobile/src/lib/mediaActions.ts @@ -1,4 +1,5 @@ import { useNavigation } from "@react-navigation/native"; +import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import type { MediaReference } from "@t3tools/client-runtime/media-reference"; import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; @@ -7,18 +8,23 @@ import { Alert } from "react-native"; import { useRefreshAssetUrl } from "../state/assets"; import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import type { DraftComposerFileAttachment } from "./composerImages"; import { copyTextWithHaptic } from "./copyTextWithHaptic"; +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; /** Authored source metadata is kept separate from temporary preview/download URLs. */ export type MediaActionsSource = { readonly reference?: MediaReference; readonly name: string; readonly mimeType: string; + /** Anchors the iOS share sheet to the view that opened the menu. */ + readonly sourceIdentifier?: string; } & ( | { readonly uri: string } + | { readonly attachment: DraftComposerFileAttachment } | { readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; + readonly threadId?: ThreadId; readonly resource: AssetResource; } ); @@ -39,12 +45,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi controller.current = request; setSharing(true); void (async () => { + if ("attachment" in source) { + const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); + if (!preview) return; + try { + await preview.share(request.signal, source.sourceIdentifier); + } finally { + preview.dispose(); + } + return; + } const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); if (request.signal.aborted) return; if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); const input = { attachment: { name: source.name, mimeType: source.mimeType }, signal: request.signal, + sourceIdentifier: source.sourceIdentifier, }; if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); else await downloadAndShareAttachment({ ...input, url: uri }); @@ -66,52 +83,62 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi }; const reference = source?.reference; - const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source - ? [ - ...(reference?.kind === "file" - ? [ - { - id: "copy-path", - title: "Copy full path", - run: () => copyTextWithHaptic(reference.path), - }, - ...(reference.relativePath - ? [ - { - id: "copy-relative-path", - title: "Copy relative path", - run: () => copyTextWithHaptic(reference.relativePath!), - }, - ] - : []), - ...(reference.relativePath && source && "environmentId" in source - ? [ - { - id: "open-file", - title: "Open in file viewer", - run: () => { - onOpenFile?.(); - navigation.navigate("ThreadFile", { - environmentId: String(source.environmentId), - threadId: String(source.threadId), - path: reference.relativePath!.split("/"), - }); - }, - }, - ] - : []), - ] - : reference - ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + const threadId = source && "threadId" in source ? source.threadId : undefined; + const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] = + source + ? [ + ...(reference?.kind === "file" + ? [ + { + id: "copy-full-path" as const, + title: "Copy full path", + run: () => copyTextWithHaptic(reference.path), + }, + ] + : []), + ...(relativePath + ? [ + { + id: "copy-relative-path" as const, + title: "Copy relative path", + run: () => copyTextWithHaptic(relativePath), + }, + ] + : []), + ...(reference?.kind === "url" + ? [ + { + id: "copy-url" as const, + title: "Copy URL", + run: () => copyTextWithHaptic(reference.url), + }, + ] + : []), + ...(relativePath && "environmentId" in source && threadId !== undefined + ? [ + { + id: "open-file" as const, + title: "Open in file viewer", + run: () => { + onOpenFile?.(); + navigation.navigate("ThreadFile", { + environmentId: String(source.environmentId), + threadId: String(threadId), + path: relativePath.split("/"), + }); + }, + }, + ] : []), - { - id: "share", - title: sharing ? "Opening share sheet…" : "Save or share", - run: share, - disabled: sharing, - }, - ] - : []; + { + id: "save" as const, + title: sharing ? "Opening share sheet…" : "Save or share", + run: share, + disabled: sharing, + }, + ] + : []; return { title: reference?.kind === "file" ? reference.path : reference?.url, actions, diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index a1c7960a570b..15e385067dcd 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -213,33 +213,35 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); - it("persists Thread List v2 shelf expansion preferences", async () => { + it("persists thread list shelf expansion preferences", async () => { await expect( savePreferencesPatch({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }), ).resolves.toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); await expect(loadPreferences()).resolves.toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); }); - it("ignores invalid Thread List v2 shelf expansion preference types", async () => { + it("drops legacy and invalid thread list shelf expansion preferences", async () => { mocks.setPreferencesJson( JSON.stringify({ baseFontSize: 17, - threadListV2SettledShelfExpanded: "false", - threadListV2SnoozedShelfExpanded: 1, + threadListV2SettledShelfExpanded: true, + threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: "false", + threadListSnoozedShelfExpanded: 1, }), 10, ); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 47bfe6755db6..61dfc0d3e859 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -800,6 +800,20 @@ describe("buildThreadFeed", () => { payload: { title: "Call repository tool", itemType: "mcp_tool_call", + toolSurface: "computer", + toolIcon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.example.Editor" }, + }, + toolSource: { + key: "native-app:com.example.editor", + name: "Computer Use", + kind: "computer", + icon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.example.Editor" }, + }, + }, detail: "repository.search", status: "completed", data: { @@ -820,7 +834,21 @@ describe("buildThreadFeed", () => { return; } - expect(group.activities[0]?.icon).toBe("wrench"); + expect(group.activities[0]?.icon).toBe("computer"); + expect(group.activities[0]?.workEntry.toolSurface).toBe("computer"); + expect(group.activities[0]?.workEntry.toolIcon).toEqual({ + _tag: "native-app", + app: { _tag: "app-id", appId: "com.example.Editor" }, + }); + expect(group.activities[0]?.workEntry.toolSource).toEqual({ + key: "native-app:com.example.editor", + name: "Computer Use", + kind: "computer", + icon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.example.Editor" }, + }, + }); expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); }); @@ -868,7 +896,7 @@ describe("buildThreadFeed", () => { title: "Call MCP tool", item: { server: "t3-code", tool: "preview_click" }, status: undefined, - displayName: "Click in the preview browser", + displayName: "Clicking in the preview browser", liveDisplayName: "Clicking in the preview browser", settledDisplayName: "Clicked in the preview browser", icon: "browser", @@ -879,7 +907,7 @@ describe("buildThreadFeed", () => { title: "Call MCP tool", item: { server: "t3-code", tool: "task_status" }, status: undefined, - displayName: "Get delegated task status", + displayName: "Getting delegated task status", liveDisplayName: "Getting delegated task status", settledDisplayName: "Got delegated task status", icon: "t3-code", @@ -1039,7 +1067,7 @@ describe("buildThreadFeed", () => { ).toMatchObject([ { type: "work-toggle", - summary: "Clicked in the preview browser", + summary: "Clicking in the preview browser", summaryToolIcon: "browser", live: true, }, @@ -1050,18 +1078,20 @@ describe("buildThreadFeed", () => { { status: "completed", displayName: "Clicked in the preview browser", + liveDisplayName: "Clicking in the preview browser", detail: "Clicked Continue", hasFailure: false, }, { status: "failed", displayName: "Failed to click in the preview browser", + liveDisplayName: "Failed to click in the preview browser", detail: "Timed out waiting for Continue", hasFailure: true, }, ])( "uses the browser call label once its action settles as $status", - ({ status, displayName, detail, hasFailure }) => { + ({ status, displayName, liveDisplayName, detail, hasFailure }) => { const turnId = TurnId.make("turn-preview-lifecycle"); const toolCallId = "preview-click"; const groupId = `work-group:tool:${turnId}:${toolCallId}`; @@ -1156,7 +1186,7 @@ describe("buildThreadFeed", () => { groupId, hiddenCount: 1, expanded: true, - summary: displayName, + summary: liveDisplayName, summaryToolIcon: "browser", hasFailure, live: true, @@ -1598,6 +1628,8 @@ describe("buildThreadFeed", () => { id: string, createdAt: string, status: ThreadFeedActivity["status"] = "success", + toolSurface?: "browser" | "computer", + toolIcon?: import("@t3tools/contracts").ToolActivityIcon, ): ThreadFeedActivity => ({ id, createdAt, @@ -1617,6 +1649,8 @@ describe("buildThreadFeed", () => { label: `Tool ${id}`, command: `command ${id}`, tone: "tool", + ...(toolSurface ? { toolSurface } : {}), + ...(toolIcon ? { toolIcon } : {}), }, }); const feed: ThreadFeedEntry[] = [ @@ -1628,8 +1662,11 @@ describe("buildThreadFeed", () => { activities: [ activity("activity-1", "2026-04-01T00:00:01.000Z"), activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"), - activity("activity-2", "2026-04-01T00:00:03.000Z"), - activity("activity-3", "2026-04-01T00:00:04.000Z"), + activity("activity-2", "2026-04-01T00:00:03.000Z", "success", "browser"), + activity("activity-3", "2026-04-01T00:00:04.000Z", "success", "computer", { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.example.Editor" }, + }), ], }, ]; @@ -1642,6 +1679,11 @@ describe("buildThreadFeed", () => { hiddenCount: 3, expanded: false, summary: "Ran 3 commands", + toolSurface: "computer", + toolIcon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.example.Editor" }, + }, }); const expanded = deriveThreadFeedPresentation( @@ -1677,7 +1719,7 @@ describe("buildThreadFeed", () => { ( [ { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, - { lifecycleStatus: "completed", summary: "Ran pnpm", shimmer: false }, + { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false }, { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c8a2ae2e953d..0e17c7bd720a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -17,6 +17,7 @@ import { commandDetailRepeatsCommand, extractCommandOutputText, isWorktreeSetupActivity, + liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, resolveWorkEntryToolPresentation, @@ -25,6 +26,7 @@ import { toolGroupSummaryKind, type ToolGroupSummaryKind, } from "@t3tools/client-runtime/work-log/presentation"; +import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array"; @@ -66,8 +68,10 @@ export interface ThreadFeedActivity { readonly icon: | "agent" | "alert" + | "browser" | "check" | "command" + | "computer" | "edit" | "eye" | "globe" @@ -98,6 +102,9 @@ export interface WorkLogEntry { changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; + toolSurface?: import("@t3tools/contracts").ToolActivitySurface; + toolIcon?: import("@t3tools/contracts").ToolActivityIcon; + toolSource?: import("@t3tools/contracts").ToolActivitySource; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; @@ -148,6 +155,8 @@ export type ThreadFeedEntry = readonly expanded: boolean; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; + readonly toolSurface?: WorkLogEntry["toolSurface"]; + readonly toolIcon?: WorkLogEntry["toolIcon"]; readonly summaryToolIcon?: "browser" | "t3-code"; readonly hasFailure: boolean; readonly live: boolean; @@ -398,6 +407,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const toolPresentation = extractToolActivityPresentation(payload); // task.updated included: terminal bypassed updates (Codex children's only // terminal signal) must carry task identity so they collapse per child // instead of stacking anonymous "Task idle" rows. @@ -479,6 +489,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } + if (toolPresentation.toolSurface) { + entry.toolSurface = toolPresentation.toolSurface; + } + if (toolPresentation.toolIcon) { + entry.toolIcon = toolPresentation.toolIcon; + } + if (toolPresentation.toolSource) { + entry.toolSource = toolPresentation.toolSource; + } if (itemType === "mcp_tool_call") { const data = asRecord(payload?.data); const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; @@ -614,6 +633,9 @@ function mergeDerivedWorkLogEntries( const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; + const toolSurface = next.toolSurface ?? previous.toolSurface; + const toolIcon = next.toolIcon ?? previous.toolIcon; + const toolSource = next.toolSource ?? previous.toolSource; const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; @@ -631,6 +653,9 @@ function mergeDerivedWorkLogEntries( ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), ...(toolTitle ? { toolTitle } : {}), + ...(toolSurface ? { toolSurface } : {}), + ...(toolIcon ? { toolIcon } : {}), + ...(toolSource ? { toolSource } : {}), ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), @@ -752,6 +777,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { return "message"; } if (entry.sourceActivityKind === "runtime.warning") return "warning"; + if (entry.toolSurface) return entry.toolSurface; if (entry.requestKind === "command") return "command"; if (entry.requestKind === "file-read") return "eye"; if (entry.requestKind === "file-change") return "edit"; @@ -1529,7 +1555,7 @@ function appendToolGroupRows( const latestActivity = latestActiveActivity ?? activities.at(-1)!; const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live - ? liveToolActivitySummary(latestActivity, active) + ? liveToolActivitySummary(latestActivity, live) : singleActivity !== null && singleActivity.toolLike && toolGroupAction(singleActivity.workEntry) !== "edit" @@ -1537,6 +1563,27 @@ function appendToolGroupRows( : singleActivity !== null && !singleActivity.toolLike ? singleActivity.workEntry.label : summarizeToolGroup(activities.map((activity) => activity.workEntry)); + const primarySourceActivity = activities.find( + (activity) => activity.workEntry.toolSource !== undefined, + ); + const primarySourceKey = primarySourceActivity?.workEntry.toolSource?.key; + const primarySourceIcon = primarySourceKey + ? (activities.find( + (activity) => + activity.workEntry.toolSource?.key === primarySourceKey && + activity.workEntry.toolIcon !== undefined, + )?.workEntry.toolIcon ?? primarySourceActivity?.workEntry.toolSource?.icon) + : undefined; + const groupToolSurface = + primarySourceActivity?.workEntry.toolSurface ?? + latestActivity.workEntry.toolSurface ?? + activities.findLast((activity) => activity.workEntry.toolSurface !== undefined)?.workEntry + .toolSurface; + const groupToolIcon = + primarySourceIcon ?? + latestActivity.workEntry.toolIcon ?? + activities.findLast((activity) => activity.workEntry.toolIcon !== undefined)?.workEntry + .toolIcon; const summaryToolIcon = live ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon : singleActivity !== null && @@ -1556,6 +1603,8 @@ function appendToolGroupRows( summaryKind: toolGroupSummaryKind( (live ? [latestActivity] : activities).map((activity) => activity.workEntry), ), + ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), + ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), ...(summaryToolIcon ? { summaryToolIcon } : {}), hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", live, @@ -1581,16 +1630,16 @@ function appendToolGroupRows( }); } -function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string { - const presentation = resolveWorkEntryToolPresentation( - activity.workEntry, - active ? "inProgress" : "completed", - ); +function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string { + const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense); + const presentation = resolveWorkEntryToolPresentation({ + ...activity.workEntry, + toolLifecycleStatus: status, + }); if (presentation) return presentation.displayName; const command = activity.workEntry.command?.trim(); if (command) { const program = commandProgramName(command); - const status = activity.lifecycleStatus ?? (active ? "inProgress" : "completed"); const verb = status === "inProgress" ? "Running" diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index af87a8d0f73a..90db612d002f 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -1,4 +1,5 @@ import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; import type { DraftComposerFileAttachment } from "./composerImages"; import type { MediaActionsSource } from "./mediaActions"; @@ -14,7 +15,7 @@ export type MediaVideoPreviewSource = { | { readonly uri: string } | { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract; } ); @@ -32,23 +33,51 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string return JSON.stringify( "uri" in source ? ["media-video", source.uri] - : [ - "media-video", - source.environmentId, - source.resource.threadId, - source.resource.path, - source.srcFragment ?? "", - ], + : source.resource._tag === "attachment" + ? ["media-video", source.environmentId, "attachment", source.resource.attachmentId] + : [ + "media-video", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ], ); } -export type AttachmentVideoPreviewSource = ( - | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } - | { - readonly type: "remote"; - readonly environmentId: EnvironmentId; - readonly attachment: ChatFileAttachment; - } -) & { readonly sourceIdentifier?: string }; +export interface LocalVideoPreviewSource { + readonly type: "local"; + readonly attachment: DraftComposerFileAttachment; + readonly sourceIdentifier?: string; +} + +export type VideoPreviewSource = LocalVideoPreviewSource | MediaVideoPreviewSource; -export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; +export function attachmentVideoPreviewSource( + environmentId: EnvironmentId, + attachment: ChatFileAttachment, + sourceIdentifier?: string, +): MediaVideoPreviewSource { + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const resource = { + _tag: "attachment" as const, + attachmentId: attachment.id, + fileName: attachment.name, + mimeType, + }; + return { + type: "media", + name: attachment.name, + mimeType, + ...(sourceIdentifier ? { sourceIdentifier } : {}), + environmentId, + resource, + actionsSource: { + name: attachment.name, + mimeType, + ...(sourceIdentifier ? { sourceIdentifier } : {}), + environmentId, + resource, + }, + }; +} diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts index dd6d040300ac..4f745aed8851 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts @@ -17,6 +17,20 @@ describe("hasWideMarkdownBlock", () => { expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true); }); + it("detects indented code blocks", () => { + const prompt = 'before\n\n def search(x):\n return x\n\n"""\n\nafter'; + expect(hasWideMarkdownBlock(prompt)).toBe(true); + expect(hasWideMarkdownBlock("before\n\n\treturn x\n\nafter")).toBe(true); + expect(hasWideMarkdownBlock("before\n\n \treturn x\n\nafter")).toBe(true); + expect(hasWideMarkdownBlock("> return x")).toBe(true); + expect(hasWideMarkdownBlock("> > \treturn x")).toBe(true); + expect(hasWideMarkdownBlock(" 1. indented code")).toBe(true); + expect(hasWideMarkdownBlock(" - code-like bullet")).toBe(true); + expect(hasWideMarkdownBlock("before\n not code\nafter")).toBe(false); + expect(hasWideMarkdownBlock("before\n \nafter")).toBe(false); + expect(hasWideMarkdownBlock("before\n \t\nafter")).toBe(false); + }); + it("detects top-level and blockquoted ordered-list markers", () => { expect(hasWideMarkdownBlock("1. One\n2. Two\n3. Three\n4. Four\n5. Five")).toBe(true); expect(hasWideMarkdownBlock("before\n3) Three")).toBe(true); @@ -24,11 +38,9 @@ describe("hasWideMarkdownBlock", () => { expect(hasWideMarkdownBlock("> > 3) Three")).toBe(true); }); - it("detects nested ordered lists without treating indented code as a list", () => { + it("detects nested ordered lists", () => { expect(hasWideMarkdownBlock("- Parent\n 1. Child\n 2. Child")).toBe(true); expect(hasWideMarkdownBlock("> - Parent\n> 1. Child")).toBe(true); - expect(hasWideMarkdownBlock(" 1. indented code")).toBe(false); - expect(hasWideMarkdownBlock(" - code-like bullet\n 1. indented code")).toBe(false); }); it("can limit ordered-list width pinning to Android", () => { @@ -46,7 +58,7 @@ describe("hasWideMarkdownBlock", () => { expect(hasWideMarkdownBlock(" > quoted", { includeBlockquotes: true })).toBe(true); expect(hasWideMarkdownBlock("> quoted")).toBe(false); expect(hasWideMarkdownBlock("prose > quoted", { includeBlockquotes: true })).toBe(false); - expect(hasWideMarkdownBlock(" > indented code", { includeBlockquotes: true })).toBe(false); + expect(hasWideMarkdownBlock(" > indented code", { includeBlockquotes: true })).toBe(true); }); it("detects GFM tables", () => { diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts index c4bd2864e472..57588bab2a27 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -1,7 +1,7 @@ /** * Detects markdown that the renderer draws as a block requiring a definite - * user-bubble width: fenced code blocks, GFM tables, ordered lists, and - * blockquotes when requested by the caller. + * user-bubble width: fenced and indented code blocks, GFM tables, ordered + * lists, and blockquotes when requested by the caller. * * Fenced code blocks and tables report an intrinsic width equal to their * widest line, which is effectively unbounded. A user bubble sizes itself @@ -43,6 +43,28 @@ function stripBlockquotePrefixes(line: string): string { return content; } +function hasIndentedCodeBlock(text: string): boolean { + return text.split("\n").some((rawLine) => { + const line = stripBlockquotePrefixes(rawLine); + let column = 0; + let index = 0; + + // Markdown tabs advance to the next four-column stop. + while (index < line.length) { + if (line[index] === " ") { + column += 1; + } else if (line[index] === "\t") { + column += 4 - (column % 4); + } else { + break; + } + index += 1; + } + + return column >= 4 && index < line.length && line[index] !== "\r"; + }); +} + function hasBlockquote(text: string): boolean { return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line)); } @@ -89,6 +111,9 @@ export function hasWideMarkdownBlock( if (options.includeBlockquotes === true && hasBlockquote(text)) { return true; } + if (hasIndentedCodeBlock(text)) { + return true; + } if (options.includeOrderedLists !== false && hasOrderedListItem(text)) { return true; } diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 3d50a2dbc559..71876932b789 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -365,14 +365,13 @@ const makeAvailable = Effect.gen(function* () { }).pipe( Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)), Effect.mapError(databaseError("inspect-caches")), - Effect.map( - (rows): ReadonlyArray => - rows.map((row) => ({ - environmentId: row.environmentId as EnvironmentId, - kind: row.kind, - recordCount: row.recordCount, - payloadBytes: row.payloadBytes, - })), + Effect.map((rows): ReadonlyArray => + rows.map((row) => ({ + environmentId: row.environmentId as EnvironmentId, + kind: row.kind, + recordCount: row.recordCount, + payloadBytes: row.payloadBytes, + })), ), ), loadPreferencesJson: Effect.tryPromise({ diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index cf4c29c6041c..f14a73f15cb2 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -41,10 +41,9 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; - /** Undefined preserves the default expanded Settled shelf. */ - readonly threadListV2SettledShelfExpanded?: boolean; - /** Undefined preserves the default collapsed Snoozed shelf. */ - readonly threadListV2SnoozedShelfExpanded?: boolean; + /** Fresh keys reset both shelves to collapsed when users update. */ + readonly threadListSettledShelfExpanded?: boolean; + readonly threadListSnoozedShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -102,8 +101,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; - threadListV2SettledShelfExpanded?: boolean; - threadListV2SnoozedShelfExpanded?: boolean; + threadListSettledShelfExpanded?: boolean; + threadListSnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -171,11 +170,11 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } - if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { - preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + if (typeof parsed.threadListSettledShelfExpanded === "boolean") { + preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } - if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { - preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { + preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; } return preferences; } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 9e3e43c7cdcb..af6300d8ffa6 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -1,23 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; -import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + type AssetUrlState, + assetUrlStateFromResult, + createAssetEnvironmentAtoms, + EMPTY_ASSET_URL_ATOM, +} from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; -export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); - -const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( - Atom.withLabel("mobile-asset-url:empty"), -); +export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; -export type AssetUrlState = - | { readonly _tag: "Loading" } - | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string }; +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export function useAssetUrlState( environmentId: EnvironmentId | null, @@ -29,14 +26,10 @@ export function useAssetUrlState( ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - if (result._tag === "Failure") { - return { _tag: "Failure" }; - } - if (preparedConnection._tag === "None" || result._tag !== "Success") { - return { _tag: "Loading" }; - } - const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; + return assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + ); } export function useAssetUrl( @@ -60,9 +53,10 @@ export function useRefreshAssetUrl( }); return useCallback(async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; - const result = await createUrl({ environmentId, input: { resource } }); - return result._tag === "Success" - ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) - : null; + const state = assetUrlStateFromResult( + await createUrl({ environmentId, input: { resource } }), + httpBaseUrl, + ); + return state._tag === "Success" ? state.url : null; }, [createUrl, environmentId, httpBaseUrl, resource]); } diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 5670f0d52b04..fd1b74cb4740 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -24,7 +24,6 @@ * cursors never rewind. */ -// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index c34f4750c14c..8423e7e6090f 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index a4831bda239a..96e1c445c7dc 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -22,6 +22,7 @@ import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; vi.mock("node:fs/promises", async (importOriginal) => { @@ -40,6 +41,7 @@ const testLayer = Layer.mergeAll( Layer.provide(WorkspacePaths.layer), Layer.provide(T3ProjectFileLoader.layer), ), + NativeAppIconResolver.layer.pipe(Layer.provide(configLayer)), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -607,6 +609,21 @@ describe("AssetAccess", () => { }); }).pipe(Effect.provide(testLayer)), ); + it.effect("issues signed native application icon capabilities", () => + Effect.gen(function* () { + const result = yield* issueAssetUrl({ + resource: { + _tag: "native-app-icon", + app: { _tag: "app-id", appId: "com.example.Editor" }, + }, + }); + + expect(result.relativeUrl).toMatch( + new RegExp(`^${ASSET_ROUTE_PREFIX}/[^/]+/native-app-icon\\.png$`, "u"), + ); + expect(result.expiresAt).toBeGreaterThan(0); + }).pipe(Effect.provide(testLayer)), + ); it.effect("serves document attachments inline when a viewer requests it", () => Effect.gen(function* () { diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index e402cf80d010..2551227bc541 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -12,6 +12,7 @@ import { AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + ToolActivityNativeAppReference, } from "@t3tools/contracts"; import { hostPreviewMimeTypeFromExtension, @@ -42,6 +43,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -124,6 +126,12 @@ const AssetClaimsSchema = Schema.Union([ filePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("native-app-icon"), + app: ToolActivityNativeAppReference, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -541,6 +549,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } break; } + case "native-app-icon": { + claims = { + version: 1, + kind: "native-app-icon", + app: input.resource.app, + expiresAt, + }; + fileName = "native-app-icon.png"; + break; + } } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -653,6 +671,12 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( : null; } + if (claims.kind === "native-app-icon") { + const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver; + const iconPath = yield* nativeAppIconResolver.resolve(claims.app); + return iconPath ? ({ kind: "file", path: iconPath } satisfies ResolvedAsset) : null; + } + const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index ba3539a3df40..28b20c7ba267 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import { diff --git a/apps/server/src/assets/NativeAppIconResolver.test.ts b/apps/server/src/assets/NativeAppIconResolver.test.ts new file mode 100644 index 000000000000..206f5e952cd6 --- /dev/null +++ b/apps/server/src/assets/NativeAppIconResolver.test.ts @@ -0,0 +1,75 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; + +function emptyProcessHandle() { + return 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.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +describe("resolveNativeAppIcon", () => { + it.effect("escapes Spotlight wildcards and caches misses", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const input = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push(input); + return emptyProcessHandle(); + }), + ); + const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-native-app-icon-test-", + }); + const dependencies = Layer.mergeAll( + configLayer, + Layer.succeed(HostProcessPlatform, "darwin"), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ).pipe(Layer.provideMerge(NodeServices.layer)); + const testLayer = NativeAppIconResolver.layer.pipe(Layer.provide(dependencies)); + const app = { _tag: "display-name", displayName: "Review * App" } as const; + + return Effect.gen(function* () { + const resolver = yield* NativeAppIconResolver.NativeAppIconResolver; + expect(yield* resolver.resolve(app)).toBeNull(); + expect(yield* resolver.resolve(app)).toBeNull(); + + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ command: "/usr/bin/mdfind" }); + expect(commands[0]?.args[0]).toContain("Review \\* App"); + + for (let index = 0; index < 256; index += 1) { + expect( + yield* resolver.resolve({ + _tag: "display-name", + displayName: `Missing Review App ${index}`, + }), + ).toBeNull(); + } + expect(commands).toHaveLength(257); + expect(yield* resolver.resolve(app)).toBeNull(); + expect(commands).toHaveLength(258); + }).pipe(Effect.provide(testLayer)); + }); +}); diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts new file mode 100644 index 000000000000..ba572650d408 --- /dev/null +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -0,0 +1,268 @@ +import * as NodeCrypto from "node:crypto"; +import type { ToolActivityNativeAppReference } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; + +const ICON_SIZE = 64; +const COMMAND_TIMEOUT = "5 seconds"; +const RESOLUTION_CACHE_TTL = Duration.hours(1); +const RESOLUTION_CACHE_MAX_ENTRIES = 256; + +/** Resolves and caches macOS application icons without exposing host paths to clients. */ +export class NativeAppIconResolver extends Context.Service< + NativeAppIconResolver, + { + /** Returns a cached PNG path for the application, or `null` when no icon is available. */ + readonly resolve: (app: ToolActivityNativeAppReference) => Effect.Effect; + } +>()("t3/assets/NativeAppIconResolver") {} + +function appCacheKey(app: ToolActivityNativeAppReference): string { + return JSON.stringify(app); +} + +function appFromCacheKey(key: string): ToolActivityNativeAppReference { + return JSON.parse(key) as ToolActivityNativeAppReference; +} + +const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(filePath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + return Option.isSome(info) && info.value.type === "File" ? filePath : null; +}); + +const commandOutput = Effect.fn("NativeAppIconResolver.commandOutput")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string(ChildProcess.make(command, args, { stdin: "ignore", stderr: "ignore" })) + .pipe(Effect.timeout(COMMAND_TIMEOUT)); +}); + +const plistValue = Effect.fn("NativeAppIconResolver.plistValue")(function* ( + infoPlistPath: string, + key: string, +) { + return yield* commandOutput("/usr/bin/plutil", [ + "-extract", + key, + "raw", + "-o", + "-", + infoPlistPath, + ]).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + ); +}); + +function escapeSpotlightString(value: string): string { + return value.replace(/([\\'*?])/gu, "\\$1"); +} + +function containsControlCharacter(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); +} + +const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicationPath")(function* ( + app: ToolActivityNativeAppReference, +) { + const path = yield* Path.Path; + const query = + app._tag === "app-id" + ? `kMDItemCFBundleIdentifier == '${app.appId}'` + : `kMDItemContentType == 'com.apple.application-bundle' && kMDItemDisplayName == '${escapeSpotlightString(app.displayName)}'`; + const spotlightOutput = yield* commandOutput("/usr/bin/mdfind", [query]); + const candidates = spotlightOutput + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter((value) => value.endsWith(".app")); + const matchingCandidates = + app._tag === "app-id" + ? candidates + : candidates.filter( + (value) => + path.basename(value, ".app").toLocaleLowerCase() === + app.displayName.toLocaleLowerCase(), + ); + const rankedCandidates = matchingCandidates.length > 0 ? matchingCandidates : candidates; + let mostRecentlyUsed: { readonly path: string; readonly lastUsed: string } | null = null; + for (const candidate of rankedCandidates) { + const lastUsed = yield* commandOutput("/usr/bin/mdls", [ + "-raw", + "-name", + "kMDItemLastUsedDate", + candidate, + ]).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + ); + if (!mostRecentlyUsed || lastUsed > mostRecentlyUsed.lastUsed) { + mostRecentlyUsed = { path: candidate, lastUsed }; + } + } + return mostRecentlyUsed?.path ?? null; +}); + +const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUncached")(function* ( + app: ToolActivityNativeAppReference, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const appPath = yield* resolveApplicationPath(app); + if (!appPath) return null; + + const canonicalAppPath = yield* fileSystem.realPath(appPath); + const infoPlistPath = path.join(canonicalAppPath, "Contents", "Info.plist"); + const resourcesDirectory = path.join(canonicalAppPath, "Contents", "Resources"); + const iconName = + (yield* plistValue(infoPlistPath, "CFBundleIconFile")) || + (yield* plistValue(infoPlistPath, "CFBundleIconName")); + if (iconName && path.basename(iconName) !== iconName) return null; + const iconFileName = iconName ? (path.extname(iconName) ? iconName : `${iconName}.icns`) : null; + const resourceEntries = yield* fileSystem + .readDirectory(resourcesDirectory) + .pipe(Effect.orElseSucceed(() => [])); + const sourceIconCandidate = + (iconFileName ? yield* existingFile(path.join(resourcesDirectory, iconFileName)) : null) ?? + (yield* existingFile(path.join(resourcesDirectory, "AppIcon.icns"))) ?? + (resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns")) + ? yield* existingFile( + path.join( + resourcesDirectory, + resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))!, + ), + ) + : null); + if (!sourceIconCandidate) return null; + const sourceIconPath = yield* fileSystem.realPath(sourceIconCandidate); + const relativeSource = path.relative(resourcesDirectory, sourceIconPath); + if ( + relativeSource === ".." || + relativeSource.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeSource) + ) { + return null; + } + + const appVersion = + (yield* plistValue(infoPlistPath, "CFBundleVersion")) || + (yield* plistValue(infoPlistPath, "CFBundleShortVersionString")); + const cacheKey = NodeCrypto.createHash("sha256") + .update(`${canonicalAppPath}\0${appVersion}\0${sourceIconPath}`) + .digest("hex"); + const cacheDirectory = path.join(config.providerStatusCacheDir, "native-app-icons"); + const cachePath = path.join(cacheDirectory, `${cacheKey}.png`); + if (yield* existingFile(cachePath)) return cachePath; + + yield* fileSystem.makeDirectory(cacheDirectory, { recursive: true }); + const temporaryPath = path.join( + cacheDirectory, + `.${cacheKey}-${process.pid}-${(yield* Clock.currentTimeMillis).toString(36)}-${NodeCrypto.randomUUID()}.png`, + ); + yield* commandOutput("/usr/bin/sips", [ + "-z", + String(ICON_SIZE), + String(ICON_SIZE), + "-s", + "format", + "png", + sourceIconPath, + "--out", + temporaryPath, + ]).pipe( + Effect.tap(() => fileSystem.rename(temporaryPath, cachePath)), + Effect.ensuring( + fileSystem.remove(temporaryPath).pipe(Effect.catchTags({ PlatformError: () => Effect.void })), + ), + ); + return yield* existingFile(cachePath); +}); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const hostPlatform = yield* HostProcessPlatform; + const resolutionSemaphore = yield* Semaphore.make(2); + const resolutionCache: Cache.Cache< + string, + string | null, + PlatformError.PlatformError | Cause.TimeoutError + > = yield* Cache.makeWith( + (key: string) => + resolutionSemaphore.withPermits(1)(resolveNativeAppIconUncached(appFromCacheKey(key))), + { + capacity: RESOLUTION_CACHE_MAX_ENTRIES, + timeToLive: Exit.match({ + onSuccess: () => RESOLUTION_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + + const cachedFileExists = (filePath: string) => + fileSystem.stat(filePath).pipe( + Effect.map((info) => info.type === "File"), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error), + }), + ); + + const resolveAttempt = Effect.fn("NativeAppIconResolver.resolve")(function* ( + app: ToolActivityNativeAppReference, + ) { + if ( + hostPlatform !== "darwin" || + (app._tag === "display-name" && containsControlCharacter(app.displayName)) + ) { + return null; + } + + const key = appCacheKey(app); + const cached = yield* Cache.get(resolutionCache, key); + if (cached === null) return null; + if (yield* cachedFileExists(cached)) return cached; + + yield* Cache.invalidate(resolutionCache, key); + return yield* Cache.get(resolutionCache, key); + }); + + const resolve: NativeAppIconResolver["Service"]["resolve"] = (app) => + resolveAttempt(app).pipe( + Effect.tapError((cause) => + Effect.logDebug("Failed to resolve native application icon.", { app, cause }), + ), + Effect.orElseSucceed(() => null), + ); + + return NativeAppIconResolver.of({ resolve }); +}); + +export const layer = Layer.effect(NativeAppIconResolver, make); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 08838cb7b780..2d0f02274de9 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -923,12 +923,10 @@ export const make = Effect.gen(function* () { const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => - clientSessions.map( - (clientSession): AuthClientSession => ({ - ...clientSession, - current: clientSession.sessionId === currentSessionId, - }), - ), + clientSessions.map((clientSession): AuthClientSession => ({ + ...clientSession, + current: clientSession.sessionId === currentSessionId, + })), ), Effect.withSpan("EnvironmentAuth.listClientSessions"), ); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 860fd1e524b2..11b60289ddb6 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -81,6 +81,8 @@ export const RPC_REQUIRED_SCOPES = { // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index b0867b62f6c8..c4443a7301cb 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -89,9 +89,10 @@ export const waitForLoopbackAuthorization = Effect.fn( while (true) { const result = yield* Effect.raceFirst( input.callback.pipe( - Effect.map( - (code): LoopbackAuthorizationResult => ({ _tag: "AuthorizationCode", code }), - ), + Effect.map((code): LoopbackAuthorizationResult => ({ + _tag: "AuthorizationCode", + code, + })), ), readLoopbackAuthorizationAction(terminalInput), ); diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 8aeb7ba24715..fecf457046d9 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -61,21 +61,19 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { Effect.map((snapshot) => { const processes = snapshot.processes .filter((entry) => canSignalCategory(entry.category)) - .map( - (entry): ServerProcessDiagnosticsEntry => ({ - pid: entry.identity.pid, - startTimeMs: entry.identity.startTimeMs, - ppid: entry.ppid, - pgid: Option.none(), - status: entry.status || "Unknown", - cpuPercent: entry.cpuPercent, - rssBytes: entry.residentBytes, - elapsed: formatElapsed(entry.runTimeMs), - command: entry.command || entry.name || "unknown", - depth: Math.max(0, entry.depth - 1), - childPids: entry.childPids, - }), - ); + .map((entry): ServerProcessDiagnosticsEntry => ({ + pid: entry.identity.pid, + startTimeMs: entry.identity.startTimeMs, + ppid: entry.ppid, + pgid: Option.none(), + status: entry.status || "Unknown", + cpuPercent: entry.cpuPercent, + rssBytes: entry.residentBytes, + elapsed: formatElapsed(entry.runTimeMs), + command: entry.command || entry.name || "unknown", + depth: Math.max(0, entry.depth - 1), + childPids: entry.childPids, + })); return { serverPid: process.pid, readAt: snapshot.readAt, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 38526c277860..5b6bb34ab0ee 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -20,6 +20,7 @@ import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; +import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( "ServerEnvironmentIdPersistenceError", @@ -188,6 +189,7 @@ export const make = Effect.gen(function* () { const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const machine = yield* detectServerEnvironmentMachineKind(); const launcher = yield* resolveServiceLauncherMode(); const serverSelfUpdate = resolveServerSelfUpdateCapability({ desktopManaged: serverConfig.mode === "desktop", @@ -206,6 +208,7 @@ export const make = Effect.gen(function* () { platform: { os: platformOs(hostPlatform), arch: platformArch(hostArchitecture), + ...(machine === null ? {} : { machine }), }, serverVersion: packageJson.version, capabilities: { @@ -223,6 +226,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, + environmentIcon: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts new file mode 100644 index 000000000000..c4318e7a9d7b --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { vi } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; +import { + detectServerEnvironmentMachineKind, + machineKindFromAppleProductName, + machineKindFromDmi, +} from "./ServerEnvironmentMachine.ts"; + +const runMock = vi.fn(); + +const ProcessRunnerTest = Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input) }), +); + +const processOutput = (stdout: string, code = 0) => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + +const dmiFileSystem = (files: Readonly>) => + FileSystem.layerNoop({ + readFileString: (path) => { + const name = path.slice(path.lastIndexOf("/") + 1); + return name in files + ? Effect.succeed(files[name]!) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + cause: new Error("ENOENT"), + }), + ); + }, + }); + +const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) => + Layer.mergeAll(ProcessRunnerTest, fileSystem, Layer.succeed(HostProcessPlatform, platform)); + +afterEach(() => { + runMock.mockReset(); +}); + +describe("machineKindFromAppleProductName", () => { + it("maps marketing names and model identifiers", () => { + expect(machineKindFromAppleProductName("Mac mini (2024)")).toBe("mac-mini"); + expect(machineKindFromAppleProductName("Macmini8,1")).toBe("mac-mini"); + expect(machineKindFromAppleProductName("Mac Studio (2023)")).toBe("mac-studio"); + expect(machineKindFromAppleProductName("MacBook Pro (14-inch, 2024)")).toBe("laptop"); + expect(machineKindFromAppleProductName("MacBookAir10,1")).toBe("laptop"); + expect(machineKindFromAppleProductName("iMac (24-inch, 2024)")).toBe("desktop"); + expect(machineKindFromAppleProductName("Mac Pro (2023)")).toBe("desktop"); + }); + + it("returns null for Apple silicon model identifiers, which carry no product family", () => { + expect(machineKindFromAppleProductName("Mac16,10")).toBeNull(); + }); +}); + +describe("machineKindFromDmi", () => { + it("prefers virtualization markers over chassis type", () => { + expect( + machineKindFromDmi({ chassisType: "1", sysVendor: "QEMU", productName: "Standard PC" }), + ).toBe("cloud"); + expect( + machineKindFromDmi({ + chassisType: "3", + sysVendor: "Microsoft Corporation", + productName: "Virtual Machine", + }), + ).toBe("cloud"); + expect( + machineKindFromDmi({ chassisType: "1", sysVendor: "Amazon EC2", productName: "t3.large" }), + ).toBe("cloud"); + }); + + it("does not treat Microsoft hardware as a VM", () => { + expect( + machineKindFromDmi({ + chassisType: "9", + sysVendor: "Microsoft Corporation", + productName: "Surface Laptop 5", + }), + ).toBe("laptop"); + }); + + it("maps SMBIOS chassis codes", () => { + expect( + machineKindFromDmi({ chassisType: "3", sysVendor: "GMKtec", productName: "NucBox K8 Plus" }), + ).toBe("desktop"); + expect( + machineKindFromDmi({ chassisType: "10", sysVendor: "LENOVO", productName: "ThinkPad X1" }), + ).toBe("laptop"); + expect( + machineKindFromDmi({ chassisType: "23", sysVendor: "Supermicro", productName: "X11" }), + ).toBe("server"); + expect(machineKindFromDmi({ chassisType: "1", sysVendor: null, productName: null })).toBeNull(); + expect( + machineKindFromDmi({ chassisType: null, sysVendor: null, productName: null }), + ).toBeNull(); + }); + + it("recognizes Apple hardware running Linux", () => { + expect( + machineKindFromDmi({ chassisType: "3", sysVendor: "Apple", productName: "Mac Studio" }), + ).toBe("mac-studio"); + }); +}); + +describe("detectServerEnvironmentMachineKind", () => { + it.effect("reads the IOKit product name on macOS", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + processOutput( + '+-o product \n {\n "product-name" = <"Mac mini (2024)">\n }\n', + ), + ); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBe("mac-mini"); + expect(runMock).toHaveBeenCalledTimes(1); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ command: "ioreg", args: ["-rd1", "-n", "product"] }), + ); + }), + ); + + it.effect("falls back to hw.model when IOKit has no product node", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce(processOutput("", 1)); + runMock.mockReturnValueOnce(processOutput("MacBookPro16,1\n")); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBe("laptop"); + expect(runMock).toHaveBeenLastCalledWith( + expect.objectContaining({ command: "sysctl", args: ["-n", "hw.model"] }), + ); + }), + ); + + it.effect("returns null when both macOS probes fail", () => + Effect.gen(function* () { + runMock.mockImplementation((input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: new Error("ENOENT"), + }), + ), + ); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBeNull(); + expect(runMock).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("reads DMI on Linux", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide( + withPlatform( + "linux", + dmiFileSystem({ + chassis_type: "3\n", + sys_vendor: "GMKtec\n", + product_name: "NucBox K8 Plus\n", + }), + ), + ), + ); + + expect(result).toBe("desktop"); + expect(runMock).not.toHaveBeenCalled(); + }), + ); + + it.effect("returns null on Linux without DMI (containers, ARM boards)", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("linux", dmiFileSystem({}))), + ); + + expect(result).toBeNull(); + }), + ); + + it.effect("skips detection on other platforms", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("win32")), + ); + + expect(result).toBeNull(); + expect(runMock).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts new file mode 100644 index 000000000000..e23342d12c09 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -0,0 +1,167 @@ +import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * Best-effort hardware detection for the environment icon. Every probe is + * allowed to fail: a null result means "no signal", and the client draws a + * generic server until the user picks something in Settings → Connections. + */ + +const DMI_ROOT = "/sys/class/dmi/id"; + +// SMBIOS 3.x System Enclosure types (table 17). Codes that describe a shape +// rather than a machine (docking stations, blades enclosures, IoT gateways) +// fall through to null on purpose. +const DMI_CHASSIS_KINDS: Readonly> = { + "3": "desktop", // Desktop + "4": "desktop", // Low Profile Desktop + "5": "desktop", // Pizza Box + "6": "desktop", // Mini Tower + "7": "desktop", // Tower + "8": "laptop", // Portable + "9": "laptop", // Laptop + "10": "laptop", // Notebook + "13": "desktop", // All in One + "14": "laptop", // Sub Notebook + "15": "desktop", // Space-saving + "16": "desktop", // Lunch Box + "17": "server", // Main Server Chassis + "18": "server", // Expansion Chassis + "19": "server", // SubChassis + "20": "server", // Bus Expansion Chassis + "21": "server", // Peripheral Chassis + "22": "server", // RAID Chassis + "23": "server", // Rack Mount Chassis + "24": "server", // Sealed-case PC + "28": "server", // Blade + "31": "laptop", // Convertible + "32": "laptop", // Detachable + "35": "desktop", // Mini PC +}; + +// Hypervisors and cloud providers write themselves into the DMI vendor or +// product strings; any hit means the box is a VM, and a VM reads as "cloud" +// regardless of the chassis type the hypervisor fakes. Hyper-V is matched on +// its "Virtual Machine" product, not the "Microsoft Corporation" vendor that +// physical Surface devices share. +const VIRTUALIZATION_MARKERS = [ + "qemu", + "kvm", + "bochs", + "vmware", + "virtualbox", + "innotek", + "xen", + "parallels", + "amazon ec2", + "google compute engine", + "digitalocean", + "hetzner", + "linode", + "vultr", + "scaleway", + "openstack", + "cloud", + "virtual machine", +]; + +function normalize(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +/** Marketing names and Intel-era model identifiers share these prefixes. */ +export function machineKindFromAppleProductName(name: string): EnvironmentMachineKind | null { + const normalized = name.trim().toLowerCase().replaceAll(/\s+/g, ""); + if (normalized.startsWith("macmini")) return "mac-mini"; + if (normalized.startsWith("macstudio")) return "mac-studio"; + if (normalized.startsWith("macbook")) return "laptop"; + if (normalized.startsWith("imac") || normalized.startsWith("macpro")) return "desktop"; + return null; +} + +export function machineKindFromDmi(input: { + readonly chassisType: string | null; + readonly sysVendor: string | null; + readonly productName: string | null; +}): EnvironmentMachineKind | null { + const productName = input.productName ?? ""; + const vendorAndProduct = `${input.sysVendor ?? ""} ${productName}`.toLowerCase(); + if (VIRTUALIZATION_MARKERS.some((marker) => vendorAndProduct.includes(marker))) { + return "cloud"; + } + // Apple hardware booting Linux (Asahi) still reports the Apple product name. + const appleKind = machineKindFromAppleProductName(productName); + if (appleKind !== null) { + return appleKind; + } + return input.chassisType === null ? null : (DMI_CHASSIS_KINDS[input.chassisType] ?? null); +} + +const readOptionalFile = Effect.fn("readOptionalFile")(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.readFileString(path).pipe( + Effect.map(normalize), + Effect.catch(() => Effect.succeed(null)), + ); +}); + +const runProbe = Effect.fn("runMachineProbe")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; +}) { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner + .run({ + command: input.command, + args: input.args, + timeout: "5 seconds", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.map((result) => (result.code === 0 ? normalize(result.stdout) : null)), + Effect.catch(() => Effect.succeed(null)), + ); +}); + +// IOKit's `product` node carries the marketing name ("Mac mini (2024)") on +// Apple silicon; Intel Macs lack it, so `hw.model` ("Macmini8,1") is the +// fallback. Both are single-digit-millisecond calls. +const detectDarwinMachineKind = Effect.fn("detectDarwinMachineKind")(function* () { + const ioreg = yield* runProbe({ command: "ioreg", args: ["-rd1", "-n", "product"] }); + const productName = ioreg?.match(/"product-name"\s*=\s*<"([^"]+)">/)?.[1] ?? null; + const fromProductName = + productName === null ? null : machineKindFromAppleProductName(productName); + if (fromProductName !== null) { + return fromProductName; + } + const model = yield* runProbe({ command: "sysctl", args: ["-n", "hw.model"] }); + return model === null ? null : machineKindFromAppleProductName(model); +}); + +const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () { + const [chassisType, sysVendor, productName] = yield* Effect.all([ + readOptionalFile(`${DMI_ROOT}/chassis_type`), + readOptionalFile(`${DMI_ROOT}/sys_vendor`), + readOptionalFile(`${DMI_ROOT}/product_name`), + ]); + return machineKindFromDmi({ chassisType, sysVendor, productName }); +}); + +export const detectServerEnvironmentMachineKind = Effect.fn("detectServerEnvironmentMachineKind")( + function* () { + const platform = yield* HostProcessPlatform; + switch (platform) { + case "darwin": + return yield* detectDarwinMachineKind(); + case "linux": + return yield* detectLinuxMachineKind(); + default: + return null; + } + }, +); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b911dd63f0c8..5a2a3a5520e6 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -73,8 +73,7 @@ export function downloadContentDisposition(fileName?: string): string { return "attachment"; } // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates. - // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls. - const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_"); + const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_"); const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_"); const needsExtended = asciiFallback !== sanitized; const extendedName = encodeURIComponent(sanitized).replace( diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 079e7a14edac..6c32235c27f8 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -207,6 +207,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); + assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 87975a49de2c..44ca928e63bb 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -64,30 +64,29 @@ export const normalizeMcpHttpResponse = ( }; const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( - Effect.map( - (registry): McpAuthMiddleware => - Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { - const request = yield* HttpServerRequest.HttpServerRequest; - const authorization = request.headers.authorization; - const token = - authorization?.startsWith("Bearer ") === true - ? authorization.slice("Bearer ".length).trim() - : ""; - const invocation = yield* registry.resolve(token); - if (!invocation) { - // Without this the only symptom of a dead credential is the agent - // quietly losing the whole `t3-code` toolkit for the rest of its - // session, with nothing on the server to explain why. - yield* Effect.logWarning("rejected MCP request with an unusable credential", { - reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", - }); - return unauthorized; - } - return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.map(normalizeMcpHttpResponse), - ); - }), + Effect.map((registry): McpAuthMiddleware => + Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) { + // Without this the only symptom of a dead credential is the agent + // quietly losing the whole `t3-code` toolkit for the rest of its + // session, with nothing on the server to explain why. + yield* Effect.logWarning("rejected MCP request with an unusable credential", { + reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", + }); + return unauthorized; + } + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }), ), Effect.withSpan("McpHttpServer.makeAuthMiddleware"), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 504fa7c52542..260e3567c242 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -3423,17 +3423,20 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { model: "gpt-5", }, faviconPath: "brand/icon.svg", + projectIcon: { kind: "emoji", emoji: "🚀" }, }); const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModelSelection: string; readonly faviconPath: string | null; + readonly projectIcon: string | null; }>` SELECT scripts_json AS "scriptsJson", default_model_selection_json AS "defaultModelSelection", - favicon_path AS "faviconPath" + favicon_path AS "faviconPath", + project_icon_json AS "projectIcon" FROM projection_projects WHERE project_id = 'project-scripts' `; @@ -3443,6 +3446,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', faviconPath: "brand/icon.svg", + projectIcon: '{"kind":"emoji","emoji":"🚀"}', }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index a74730a09908..8a3077894cb7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -517,6 +517,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti defaultThreadEnvMode: null, autoPull: false, faviconPath: event.payload.faviconPath ?? null, + projectIcon: event.payload.projectIcon ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -547,6 +548,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.faviconPath !== undefined ? { faviconPath: event.payload.faviconPath } : {}), + ...(event.payload.projectIcon !== undefined + ? { projectIcon: event.payload.projectIcon } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5d8293c799ae..a5d3af37492f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -280,6 +280,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, autoPull: false, faviconPath: null, + projectIcon: null, scripts: [ { id: "script-1", @@ -409,6 +410,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, autoPull: false, faviconPath: null, + projectIcon: null, scripts: [ { id: "script-1", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index a71aea950d0e..2cec699ece3a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -12,6 +12,7 @@ import { OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, + ProjectIconOverride, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -84,6 +85,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), autoPull: Schema.Number, + projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -362,6 +364,7 @@ function mapProjectShellRow( defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, + projectIcon: row.projectIcon ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -455,6 +458,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -940,6 +944,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -965,6 +970,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1928,6 +1934,7 @@ pending_approval_requests AS ( defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, + projectIcon: row.projectIcon ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2065,6 +2072,7 @@ pending_approval_requests AS ( defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, + projectIcon: row.projectIcon ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2437,43 +2445,41 @@ pending_approval_requests AS ( ) : Result.failVoid, ), - threads: threadRows.map( - (row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - titleRegenerationFailure: mapTitleRegenerationFailure(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - }), - ), + threads: threadRows.map((row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + titleRegenerationFailure: mapTitleRegenerationFailure(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -2517,12 +2523,10 @@ pending_approval_requests AS ( "ProjectionSnapshotQuery.getCounts:decodeRow", ), ), - Effect.map( - (row): ProjectionSnapshotCounts => ({ - projectCount: row.projectCount, - threadCount: row.threadCount, - }), - ), + Effect.map((row): ProjectionSnapshotCounts => ({ + projectCount: row.projectCount, + threadCount: row.threadCount, + })), ); const getEventReplayStats: ProjectionSnapshotQueryShape["getEventReplayStats"] = (input) => @@ -2533,12 +2537,10 @@ pending_approval_requests AS ( "ProjectionSnapshotQuery.getEventReplayStats:decodeRow", ), ), - Effect.map( - (row): ProjectionEventReplayStats => ({ - eventCount: row.eventCount, - payloadBytes: row.payloadBytes, - }), - ), + Effect.map((row): ProjectionEventReplayStats => ({ + eventCount: row.eventCount, + payloadBytes: row.payloadBytes, + })), ); const searchThreads: ProjectionSnapshotQueryShape["searchThreads"] = Effect.fn( @@ -2590,6 +2592,7 @@ pending_approval_requests AS ( defaultThreadEnvMode: option.value.defaultThreadEnvMode, autoPull: option.value.autoPull === 1, faviconPath: option.value.faviconPath ?? null, + projectIcon: option.value.projectIcon ?? null, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, @@ -2663,17 +2666,15 @@ pending_approval_requests AS ( projectId: threadRow.value.projectId, workspaceRoot: threadRow.value.workspaceRoot, worktreePath: threadRow.value.worktreePath, - checkpoints: checkpointRows.map( - (row): OrchestrationCheckpointSummary => ({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }), - ), + checkpoints: checkpointRows.map((row): OrchestrationCheckpointSummary => ({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + })), }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 26332f9f8c9c..a4640179af99 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2844,6 +2844,16 @@ describe("ProviderRuntimeIngestion", () => { itemType: "command_execution", status: "inProgress", title: "Command run", + toolSurface: "computer", + toolIcon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.apple.Terminal" }, + }, + toolSource: { + key: "native-app:com.apple.terminal", + name: "Terminal", + kind: "computer", + }, detail: "Bash: vp test run", data: { toolName: "Bash", @@ -2871,6 +2881,17 @@ describe("ProviderRuntimeIngestion", () => { itemType: "command_execution", toolCallId: "tool-call-9", status: "inProgress", + title: "Command run", + toolSurface: "computer", + toolIcon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.apple.Terminal" }, + }, + toolSource: { + key: "native-app:com.apple.terminal", + name: "Terminal", + kind: "computer", + }, detail: "Bash: vp test run", data: { toolName: "Bash", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 24ecf8205cc6..52ccf6dc6168 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -844,7 +844,11 @@ export function runtimeEventToActivities( itemType: event.payload.itemType, ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), + ...(event.payload.title ? { title: event.payload.title } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), + ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), + ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId @@ -872,7 +876,11 @@ export function runtimeEventToActivities( itemType: event.payload.itemType, ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), + ...(event.payload.title ? { title: event.payload.title } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), + ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), + ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId @@ -900,7 +908,11 @@ export function runtimeEventToActivities( itemType: event.payload.itemType, ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), + ...(event.payload.title ? { title: event.payload.title } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), + ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), + ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index ef63d8b544e3..738c9109f0fb 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -25,7 +25,10 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { GitManager } from "../git/GitManager.ts"; -import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { + PullRequestService, + type PullRequestMergeEvent, +} from "../pullRequest/PullRequestService.ts"; import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -143,6 +146,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const snapshotReads = yield* Queue.unbounded(); const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsChanges = yield* PubSub.unbounded(); + const mergedPullRequests = yield* PubSub.unbounded(); const commands = yield* Ref.make>([]); const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> @@ -168,7 +172,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Ref.update(branchCalls, (calls) => [...calls, input]).pipe( Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), ); - const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => Effect.gen(function* () { yield* Ref.update(summaryCalls, (calls) => [...calls, input]); @@ -219,7 +222,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: ), }), Layer.mock(GitManager)({ branchPullRequest }), - Layer.mock(PullRequestService)({ summary: pullRequestSummary }), + Layer.mock(PullRequestService)({ + summary: pullRequestSummary, + subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch, @@ -241,6 +249,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: summaryCalls, summaryRecovery, updateSettings, + publishMerge: PubSub.publish(mergedPullRequests, { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + mergedAt: NOW, + }), layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), }; }); @@ -375,6 +389,60 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("reevaluates immediately after a pull request merge", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const periodicLookupStarted = yield* Deferred.make(); + const releasePeriodicLookup = yield* Deferred.make(); + const mergedThreadSettled = yield* Deferred.make(); + const branchLookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged-in-app", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("slow-periodic-lookup", { branch: "another-feature" }), + ]), + branchPullRequest: () => + Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) + : Deferred.succeed(periodicLookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePeriodicLookup)), + Effect.as({ state: "open" as const, updatedAt: NOW }), + ), + ), + ), + onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Deferred.await(periodicLookupStarted); + + yield* fixture.publishMerge; + yield* Deferred.await(mergedThreadSettled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("merged-in-app")], + ); + yield* Deferred.succeed(releasePeriodicLookup, undefined); + yield* reactor.drain; + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4971ae643c0a..9dd7cd5e76fd 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -38,11 +38,22 @@ export const make = Effect.gen(function* () { const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; - const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( + mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, + ) { const snapshot = yield* snapshots.getShellSnapshot(); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); - const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const candidates = snapshot.threads.filter( + (thread) => + isAutoSettlementCandidate(thread, now) && + (mergedPullRequest === null || + (thread.linkedPullRequest != null && + thread.linkedPullRequest.projectId === mergedPullRequest.projectId && + thread.linkedPullRequest.repository.toLowerCase() === + mergedPullRequest.repository.toLowerCase() && + thread.linkedPullRequest.number === mergedPullRequest.number)), + ); const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ @@ -66,6 +77,12 @@ export const make = Effect.gen(function* () { thread: (typeof candidates)[number], ) { if (thread.linkedPullRequest != null) { + if (mergedPullRequest !== null) { + return { + state: "merged", + updatedAt: mergedPullRequest.mergedAt, + } satisfies SettlementPullRequest; + } if (!projects.has(thread.linkedPullRequest.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); } @@ -145,8 +162,8 @@ export const make = Effect.gen(function* () { ); }); - const worker = yield* makeDrainableWorker(() => - sweep().pipe( + const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) => + sweep(mergedPullRequest).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) @@ -154,13 +171,14 @@ export const make = Effect.gen(function* () { cause: Cause.pretty(cause), }), ), - ), - ); + ); + const worker = yield* makeDrainableWorker(() => runSweep(null)); const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( "ThreadSettlementReactor.start", )(function* () { const settingsChanges = yield* settingsService.subscribeChanges; + const mergedPullRequests = yield* pullRequests.subscribeMerges; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; @@ -183,6 +201,7 @@ export const make = Effect.gen(function* () { return worker.enqueue(undefined); }), ); + yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep)); }); return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index bf5c509fa16b..22ff884d5f47 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,7 +94,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); - it.effect("propagates a project favicon path in project.meta.update", () => + it.effect("propagates project icon metadata in project.meta.update", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; const readModel = yield* projectEvent(createEmptyReadModel(now), { @@ -125,6 +125,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { commandId: CommandId.make("cmd-project-update-favicon"), projectId: asProjectId("project-favicon"), faviconPath: "brand/icon.svg", + projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, }, readModel, }); @@ -132,6 +133,11 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { const event = Array.isArray(result) ? result[0] : result; expect(event.type).toBe("project.meta-updated"); expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg"); + expect((event.payload as { projectIcon?: unknown }).projectIcon).toEqual({ + kind: "lucide", + name: "alarm-clock", + color: "violet", + }); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 8014c51ad325..94c01fc4544a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -224,6 +224,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // explicit project default. defaultModelSelection: null, faviconPath: null, + projectIcon: null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -266,6 +267,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}), ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), + ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 07590118d480..40da2f579c27 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -217,6 +217,7 @@ export function projectEvent( defaultThreadEnvMode: null, autoPull: false, faviconPath: payload.faviconPath ?? null, + projectIcon: payload.projectIcon ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -256,6 +257,9 @@ export function projectEvent( ...(payload.faviconPath !== undefined ? { faviconPath: payload.faviconPath } : {}), + ...(payload.projectIcon !== undefined + ? { projectIcon: payload.projectIcon } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7dcec817f9bf..f34f81294124 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -20,6 +20,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), autoPull: Schema.Number, + projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -40,6 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode, auto_pull, favicon_path, + project_icon_json, scripts_json, created_at, updated_at, @@ -53,6 +55,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.defaultThreadEnvMode}, ${row.autoPull ? 1 : 0}, ${row.faviconPath ?? null}, + ${row.projectIcon ? JSON.stringify(row.projectIcon) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -66,6 +69,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode = excluded.default_thread_env_mode, auto_pull = excluded.auto_pull, favicon_path = excluded.favicon_path, + project_icon_json = excluded.project_icon_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -86,6 +90,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -108,6 +113,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", favicon_path AS "faviconPath", + project_icon_json AS "projectIcon", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 18b797b6d433..c08ca3444b6b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -89,6 +89,9 @@ import Migration0050 from "./Migrations/050_ProjectionProjectsAutoPull.ts"; // Upstream RepairAutomaticSettlementTimestamps was runtime ID 46. Append it // after auto-pull so deployed fork IDs remain append-only. import Migration0051 from "./Migrations/051_RepairAutomaticSettlementTimestamps.ts"; +// Upstream ProjectionProjectIcon was runtime ID 47. Append it after the fork's +// already-deployed settlement-repair migration. +import Migration0052 from "./Migrations/052_ProjectionProjectIcon.ts"; /** * Migration loader with all migrations defined inline. @@ -152,6 +155,7 @@ export const migrationEntries = [ [49, "ClearAutomaticProjectModelDefaults", Migration0049], [50, "ProjectionProjectsAutoPull", Migration0050], [51, "RepairAutomaticSettlementTimestamps", Migration0051], + [52, "ProjectionProjectIcon", Migration0052], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/052_ProjectionProjectIcon.test.ts b/apps/server/src/persistence/Migrations/052_ProjectionProjectIcon.test.ts new file mode 100644 index 000000000000..c6137f5388bc --- /dev/null +++ b/apps/server/src/persistence/Migrations/052_ProjectionProjectIcon.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("052_ProjectionProjectIcon", (it) => { + it.effect("adds the nullable project icon JSON to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 51 }); + yield* runMigrations({ toMigrationInclusive: 52 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const projectIcon = columns.find((column) => column.name === "project_icon_json"); + + assert.equal(projectIcon?.name, "project_icon_json"); + assert.equal(projectIcon?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/052_ProjectionProjectIcon.ts b/apps/server/src/persistence/Migrations/052_ProjectionProjectIcon.ts new file mode 100644 index 000000000000..0523a47b1c56 --- /dev/null +++ b/apps/server/src/persistence/Migrations/052_ProjectionProjectIcon.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "project_icon_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN project_icon_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 8510dc6e7c2f..e5d1f6ba1f1d 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -9,6 +9,7 @@ import { IsoDateTime, ModelSelection, + ProjectIconOverride, ProjectId, ProjectScript, ThreadEnvMode, @@ -28,6 +29,7 @@ export const ProjectionProject = Schema.Struct({ defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), autoPull: Schema.Boolean, faviconPath: Schema.optional(Schema.NullOr(Schema.String)), + projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index a5b1f4da8db0..3c0169eba40a 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -433,15 +433,13 @@ export const make = Effect.gen(function* PreviewManagerMake() { const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")( function* (input) { return yield* SynchronizedRef.get(stateRef).pipe( - Effect.map( - (state): PreviewListResult => ({ - sessions: sessionsForThread(state, input.threadId) - .map((s) => s.snapshot) - .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), - serverEpoch, - revision: state.revision, - }), - ), + Effect.map((state): PreviewListResult => ({ + sessions: sessionsForThread(state, input.threadId) + .map((s) => s.snapshot) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + serverEpoch, + revision: state.revision, + })), ); }, ); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 16b5625d4690..c245b0411525 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -239,13 +239,11 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { }); }, ), - Effect.map( - (state): CollectedUint8StreamText => ({ - ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)), - bytes: state.bytes, - truncated: false, - }), - ), + Effect.map((state): CollectedUint8StreamText => ({ + ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)), + bytes: state.bytes, + truncated: false, + })), ); }); diff --git a/apps/server/src/provider/Layers/AmpAdapter.test.ts b/apps/server/src/provider/Layers/AmpAdapter.test.ts index f0230f05475d..74b9e59b96e0 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.test.ts @@ -42,12 +42,10 @@ class FakeAmpManager extends AmpServerManager { } as unknown as ProviderSession; }); - public sendTurnImpl = vi.fn( - async (threadId: ThreadId): Promise => ({ - threadId, - turnId: asTurnId(`turn-${threadId}`), - }), - ); + public sendTurnImpl = vi.fn(async (threadId: ThreadId): Promise => ({ + threadId, + turnId: asTurnId(`turn-${threadId}`), + })); public interruptTurnImpl = vi.fn(async (): Promise => undefined); public respondToRequestImpl = vi.fn(async (): Promise => undefined); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index b146977cd995..7ddb63741786 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -275,6 +275,30 @@ async function readFirstPromptMessage( return next.value; } +/** Drains the first `count` queued prompts so consecutive turns can be compared. */ +async function readPromptMessages( + input: + | { + readonly prompt: AsyncIterable; + } + | undefined, + count: number, +): Promise> { + const iterator = input?.prompt[Symbol.asyncIterator](); + if (!iterator) { + return []; + } + const messages: Array = []; + while (messages.length < count) { + const next = await iterator.next(); + if (next.done) { + break; + } + messages.push(next.value); + } + return messages; +} + const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; @@ -814,6 +838,101 @@ describe("ClaudeAdapterLive", () => { ); }); + // The Claude CLI reads a streamed user message as a slash-command invocation + // only when the final content block is text. Leading with the text block sent + // every image-carrying turn down the plain-prompt path, so `/skill args` + // reached the agent unexpanded with no error anywhere. + it.effect("puts the command text last so attachments do not suppress expansion", () => { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); + const harness = makeHarness({ + cwd: "/tmp/project-claude-command-attachments", + baseDir, + }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => + NodeFS.rmSync(baseDir, { + recursive: true, + force: true, + }), + ), + ); + + const adapter = yield* ClaudeAdapter; + const { attachmentsDir } = yield* ServerConfig; + + const imageAttachment = { + type: "image" as const, + id: "thread-claude-attachment-22345678-1234-1234-1234-123456789abc", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 4, + }; + const fileAttachment = { + type: "file" as const, + id: "thread-claude-attachment-32345678-1234-1234-1234-123456789abc", + name: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 4, + }; + for (const attachment of [imageAttachment, fileAttachment]) { + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); + } + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/flow-patterns hello", + attachments: [], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/flow-patterns hello", + attachments: [imageAttachment], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/flow-patterns hello", + attachments: [fileAttachment], + }); + + const prompts = yield* Effect.promise(() => + readPromptMessages(harness.getLastCreateQueryInput(), 3), + ); + const commandBlock = { + type: "text" as const, + text: "/flow-patterns hello", + }; + + assert.deepEqual(prompts[0]?.message.content, [commandBlock]); + assert.deepEqual(prompts[1]?.message.content, [ + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "AQIDBA==", + }, + }, + commandBlock, + ]); + // Non-image attachments never become content blocks. Claude reaches them + // through the path line ProviderService writes into the prompt, so the + // text block stays last on its own. + assert.deepEqual(prompts[2]?.message.content, [commandBlock]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("dispatches a $skill mention as a trailing slash command block", () => { // Claude Code only runs `/name` from the message's last text block, so a // chip picked mid-prompt is moved there and the surrounding prose kept. diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b0369ee57b98..44ef8347add0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -186,9 +186,10 @@ function toSessionPermissionUpdates( toolName: string, suggestions: ReadonlyArray | undefined, ): Array { - const sessionScoped = (suggestions ?? []).map( - (suggestion): PermissionUpdate => ({ ...suggestion, destination: "session" }), - ); + const sessionScoped = (suggestions ?? []).map((suggestion): PermissionUpdate => ({ + ...suggestion, + destination: "session", + })); if (sessionScoped.length > 0) { return sessionScoped; } @@ -1373,11 +1374,11 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( ); } - // Images go before the command block: a text block after them still - // expands, a command block followed by an image does not. Skip the extra - // text block when a command is dispatched — the command payload already - // includes the prompt and duplicating it makes Claude treat the raw input - // as a second user message. + // The final text block goes last on purpose. The Claude CLI only reads a + // streamed user message as a slash-command invocation when the last content + // block is text; image blocks ahead of it ride along as preceding input. + // Leading with the text made every image-carrying turn fall back to a plain + // prompt, so a hand-typed `/skill args` reached the agent unexpanded. if (dispatch) { sdkContent.push({ type: "text", text: dispatch.commandText }); } else if (text.length > 0) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 756b3174ed22..2c9af7236579 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -84,24 +84,22 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); - public readonly interruptTurnImpl = vi.fn( - (_turnId?: TurnId): Promise => Promise.resolve(undefined), + public readonly interruptTurnImpl = vi.fn((_turnId?: TurnId): Promise => + Promise.resolve(undefined), ); - public readonly readThreadImpl = vi.fn( - (): Promise => - Promise.resolve({ - threadId: "provider-thread-1", - turns: [], - }), + public readonly readThreadImpl = vi.fn((): Promise => + Promise.resolve({ + threadId: "provider-thread-1", + turns: [], + }), ); - public readonly rollbackThreadImpl = vi.fn( - (_numTurns: number): Promise => - Promise.resolve({ - threadId: "provider-thread-1", - turns: [], - }), + public readonly rollbackThreadImpl = vi.fn((_numTurns: number): Promise => + Promise.resolve({ + threadId: "provider-thread-1", + turns: [], + }), ); public readonly uploadFeedbackImpl = vi.fn((_reason?: string) => @@ -802,9 +800,207 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("presents browser and computer-use calls with Codex-style titles and sources", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); + const longIntentTitle = ` ${"a".repeat(39)} ${"a".repeat(38)}😀bc `; + const serializedOverContractUrl = `https://example.com/?query=${"😀".repeat(400)}`; + + yield* runtime.emit({ + id: asEventId("evt-computer-start"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/started", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("computer_1"), + payload: { + startedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "computer_1", + server: "node_repl", + tool: "js", + arguments: { + code: 'await sky.click({ app: "Finder", x: 10, y: 20 })', + title: longIntentTitle, + }, + durationMs: null, + error: null, + result: { + _meta: { + "codex/toolSurface": { + kind: "computerUse", + app: { kind: "appId", appId: "com.apple.finder" }, + }, + }, + content: [], + }, + status: "inProgress", + }, + }, + }); + yield* runtime.emit({ + id: asEventId("evt-browser-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("browser_1"), + payload: { + completedAtMs: 1_778_000_001_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "browser_1", + server: "node_repl", + tool: "js", + arguments: { code: "await tab.playwright.domSnapshot()", title: "Inspect checkout" }, + durationMs: 12, + error: null, + result: { + _meta: { + "codex/toolSurface": { + kind: "browserUse", + backend: "chrome", + openTabs: [ + { + pageUrl: "https://www.mathworks.com/help/matlab/", + faviconUrl: "https://www.mathworks.com/favicon.ico", + faviconUrlDark: "https://www.mathworks.com/favicon-dark.ico", + url: "https://www.mathworks.com/help/matlab/", + }, + ], + }, + browser_use: { url: serializedOverContractUrl }, + }, + content: [], + }, + status: "completed", + }, + }, + }); + yield* runtime.emit({ + id: asEventId("evt-computer-use-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("computer_2"), + payload: { + completedAtMs: 1_778_000_002_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "computer_2", + server: "computer-use", + tool: "type_text", + arguments: { text: "Hello world", app: "TextEdit" }, + durationMs: 12, + error: null, + result: { + _meta: { + "codex/toolSurface": { + kind: "computerUse", + app: { kind: "displayName", displayName: "TextEdit" }, + }, + }, + content: [], + }, + status: "completed", + }, + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => ({ + type: event.type, + title: "title" in event.payload ? event.payload.title : undefined, + toolSurface: "toolSurface" in event.payload ? event.payload.toolSurface : undefined, + toolIcon: "toolIcon" in event.payload ? event.payload.toolIcon : undefined, + toolSource: "toolSource" in event.payload ? event.payload.toolSource : undefined, + })), + [ + { + type: "item.started", + title: `${"a".repeat(39)} ${"a".repeat(38)}😀…`, + toolSurface: "computer", + toolIcon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.apple.finder" }, + }, + toolSource: { + key: "native-app:com.apple.finder", + name: "Finder", + kind: "computer", + icon: { + _tag: "native-app", + app: { _tag: "app-id", appId: "com.apple.finder" }, + }, + }, + }, + { + type: "item.completed", + title: "Inspect checkout", + toolSurface: "browser", + toolIcon: { + _tag: "website", + pageUrl: "https://www.mathworks.com/help/matlab/", + faviconUrl: "https://www.mathworks.com/favicon.ico", + faviconUrlDark: "https://www.mathworks.com/favicon-dark.ico", + }, + toolSource: { + key: "browser-use:chrome", + name: "Chrome", + kind: "integration", + icon: { + _tag: "native-app", + app: { _tag: "display-name", displayName: "Google Chrome" }, + }, + }, + }, + { + type: "item.completed", + title: "Typed text in TextEdit", + toolSurface: "computer", + toolIcon: { + _tag: "native-app", + app: { _tag: "display-name", displayName: "TextEdit" }, + }, + toolSource: { + key: "native-app-name:textedit", + name: "TextEdit", + kind: "computer", + icon: { + _tag: "native-app", + app: { _tag: "display-name", displayName: "TextEdit" }, + }, + }, + }, + ], + ); + }), + ); + it.effect("preserves failed and declined outcomes on completed tool items", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); + const maxLengthAppId = `com.${"a".repeat(508)}`; + const collidingMaxLengthAppId = `com.${"a".repeat(507)}b`; + const longAppSourceKeys: string[] = []; const items = [ { type: "commandExecution", @@ -824,6 +1020,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { error: { message: "Build failed" }, status: "failed", }, + { + type: "mcpToolCall", + id: "failed-computer", + server: "computer-use", + tool: "click", + arguments: { app: "Finder" }, + error: { message: "Click failed" }, + result: { + _meta: { + "codex/toolSurface": { + kind: "computerUse", + app: { kind: "appId", appId: maxLengthAppId }, + }, + }, + content: [], + }, + status: "failed", + }, + { + type: "mcpToolCall", + id: "failed-computer-collision", + server: "computer-use", + tool: "click", + arguments: { app: "Other" }, + error: { message: "Click failed" }, + result: { + _meta: { + "codex/toolSurface": { + kind: "computerUse", + app: { kind: "appId", appId: collidingMaxLengthAppId }, + }, + }, + content: [], + }, + status: "failed", + }, { type: "fileChange", id: "declined-change", @@ -858,7 +1090,14 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { return; } NodeAssert.equal(firstEvent.value.payload.status, item.status); + if (item.id.startsWith("failed-computer")) { + NodeAssert.equal(firstEvent.value.payload.title, "computer-use · click"); + const sourceKey = firstEvent.value.payload.toolSource?.key; + NodeAssert.equal(sourceKey?.length, 512); + if (sourceKey) longAppSourceKeys.push(sourceKey); + } } + NodeAssert.equal(new Set(longAppSourceKeys).size, 2); }), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 1aed82a28868..fa8511ee09a1 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -17,6 +17,9 @@ import { type ProviderRuntimeEvent, type ProviderRequestKind, type ThreadTokenUsageSnapshot, + type ToolActivityIcon, + type ToolActivityNativeAppReference, + type ToolActivitySource, type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, @@ -27,6 +30,7 @@ import { ProviderSendTurnInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as NodeCrypto from "node:crypto"; import * as Crypto from "effect/Crypto"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -150,6 +154,236 @@ function trimText(value: string | undefined | null): string | undefined { return trimmed && trimmed.length > 0 ? trimmed : undefined; } +function asUnknownRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function normalizeMcpIntentTitle(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().replace(/\s+/gu, " "); + if (!normalized) return undefined; + const characters = Array.from(normalized); + return characters.length <= 80 ? normalized : `${characters.slice(0, 79).join("")}…`; +} + +function normalizedHttpUrl(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > 4096) return undefined; + try { + const url = new URL(value); + const href = url.href; + return (url.protocol === "http:" || url.protocol === "https:") && href.length <= 4096 + ? href + : undefined; + } catch { + return undefined; + } +} + +function normalizedImageUrl(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > 4096) return undefined; + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "data:" + ? url.href + : undefined; + } catch { + return undefined; + } +} + +function normalizedAppId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const appId = value.trim(); + return appId.length > 0 && appId.length <= 512 && /^[A-Za-z0-9._-]+$/u.test(appId) + ? appId + : undefined; +} + +function normalizedDisplayName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const displayName = value.trim().replace(/\s+/gu, " "); + return displayName && displayName.length <= 160 ? displayName : undefined; +} + +function normalizedSourceKeyPart(value: string): string { + return value.trim().toLowerCase(); +} + +function nativeAppSourceKey(appId: string): string { + const key = `native-app:${appId.toLowerCase()}`; + if (key.length <= 512) return key; + const digest = NodeCrypto.createHash("sha256").update(key).digest("hex"); + return `${key.slice(0, 512 - digest.length - 1)}:${digest}`; +} + +function browserDisplayName(value: unknown): string | undefined { + const normalized = normalizedDisplayName(value)?.toLowerCase(); + if (!normalized) return undefined; + if (normalized.includes("chrome") || normalized === "chromium") return "Chrome"; + if (normalized.includes("edge")) return "Microsoft Edge"; + if (normalized.includes("firefox")) return "Firefox"; + if (normalized.includes("safari")) return "Safari"; + if (normalized.includes("arc")) return "Arc"; + if (normalized === "iab" || normalized.includes("in-app")) return "Browser"; + return normalizedDisplayName(value); +} + +function browserNativeAppReference(name: string): ToolActivityNativeAppReference | undefined { + switch (name) { + case "Chrome": + return { _tag: "display-name", displayName: "Google Chrome" }; + case "Microsoft Edge": + case "Firefox": + case "Safari": + case "Arc": + return { _tag: "display-name", displayName: name }; + default: + return undefined; + } +} + +function appDisplayNameFromId(appId: string): string | undefined { + const knownNames: Readonly> = { + "com.apple.finder": "Finder", + "com.apple.safari": "Safari", + "com.google.chrome": "Chrome", + "com.microsoft.edgemac": "Microsoft Edge", + "org.mozilla.firefox": "Firefox", + "company.thebrowser.browser": "Arc", + }; + return knownNames[appId.toLowerCase()]; +} + +function nativeAppReference(value: unknown): ToolActivityNativeAppReference | undefined { + const app = asUnknownRecord(value); + if (app?.kind === "appId") { + const appId = normalizedAppId(app.appId); + return appId ? { _tag: "app-id", appId } : undefined; + } + if (app?.kind === "displayName") { + const displayName = normalizedDisplayName(app.displayName); + return displayName ? { _tag: "display-name", displayName } : undefined; + } + return undefined; +} + +function themedLogoIcon( + ...records: ReadonlyArray | undefined> +): ToolActivityIcon | undefined { + for (const record of records) { + const logoUrl = normalizedImageUrl(record?.logoUrl); + if (!logoUrl) continue; + const logoUrlDark = normalizedImageUrl(record?.logoUrlDark ?? record?.logoDarkUrl); + return { + _tag: "themed-logo", + logoUrl, + ...(logoUrlDark ? { logoUrlDark } : {}), + }; + } + return undefined; +} + +interface McpToolPresentation { + readonly toolSurface?: "browser" | "computer"; + readonly toolIcon?: ToolActivityIcon; + readonly toolSource?: ToolActivitySource; +} + +function mcpToolPresentation( + item: Extract, +): McpToolPresentation { + const result = asUnknownRecord(item.result); + const metadata = asUnknownRecord(result?._meta); + const surface = asUnknownRecord(metadata?.["codex/toolSurface"]); + const sourceMetadata = asUnknownRecord(metadata?.source); + const appContext = asUnknownRecord(item.appContext); + const sourceLogo = themedLogoIcon(surface, sourceMetadata, appContext); + if (surface?.kind === "browserUse") { + const screenshot = asUnknownRecord(surface.screenshot); + const browserUse = asUnknownRecord(metadata?.browser_use); + const openTabs = Array.isArray(surface.openTabs) ? surface.openTabs : []; + const latestOpenTab = openTabs + .toReversed() + .map(asUnknownRecord) + .find((tab) => normalizedHttpUrl(tab?.url) !== undefined); + const selectedPage = [ + { record: screenshot, url: screenshot?.pageUrl }, + { record: browserUse, url: browserUse?.url }, + { record: latestOpenTab, url: latestOpenTab?.url }, + ] + .map((candidate) => ({ ...candidate, pageUrl: normalizedHttpUrl(candidate.url) })) + .find((candidate) => candidate.pageUrl !== undefined); + const pageUrl = selectedPage?.pageUrl; + const faviconUrl = normalizedImageUrl( + selectedPage?.record?.faviconUrl ?? selectedPage?.record?.favIconUrl, + ); + const faviconUrlDark = normalizedImageUrl( + selectedPage?.record?.faviconUrlDark ?? selectedPage?.record?.favIconUrlDark, + ); + const name = + browserDisplayName(appContext?.appName) ?? + browserDisplayName(surface.browserFamily) ?? + browserDisplayName(surface.backend) ?? + "Browser"; + const nativeBrowserIcon = browserNativeAppReference(name); + const sourceIcon = + sourceLogo ?? + (nativeBrowserIcon ? ({ _tag: "native-app", app: nativeBrowserIcon } as const) : undefined); + const sourceKeyPart = normalizedSourceKeyPart(name) || "browser"; + return { + toolSurface: "browser", + ...(pageUrl + ? { + toolIcon: { + _tag: "website", + pageUrl, + ...(faviconUrl ? { faviconUrl } : {}), + ...(faviconUrlDark ? { faviconUrlDark } : {}), + } as const, + } + : {}), + toolSource: { + key: `browser-use:${sourceKeyPart}`, + name, + kind: name === "Browser" ? "browser" : "integration", + ...(sourceIcon ? { icon: sourceIcon } : {}), + }, + }; + } + if (surface?.kind === "computerUse") { + const app = nativeAppReference(surface.app); + const args = asUnknownRecord(item.arguments); + const argumentAppName = + normalizedDisplayName(args?.appName) ?? + normalizedDisplayName(args?.application) ?? + normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined); + const name = + normalizedDisplayName(appContext?.appName) ?? + argumentAppName ?? + (app?._tag === "display-name" ? app.displayName : undefined) ?? + (app?._tag === "app-id" ? appDisplayNameFromId(app.appId) : undefined) ?? + "Computer Use"; + const sourceIcon = sourceLogo ?? (app ? ({ _tag: "native-app", app } as const) : undefined); + const sourceKey = app + ? app._tag === "app-id" + ? nativeAppSourceKey(app.appId) + : `native-app-name:${normalizedSourceKeyPart(app.displayName)}` + : "computer-use"; + return { + toolSurface: "computer", + ...(app ? { toolIcon: { _tag: "native-app", app } as const } : {}), + toolSource: { + key: sourceKey, + name, + kind: "computer", + ...(sourceIcon ? { icon: sourceIcon } : {}), + }, + }; + } + + return {}; +} + const FATAL_CODEX_STDERR_SNIPPETS = ["failed to connect to websocket"]; function isFatalCodexProcessStderrMessage(message: string): boolean { @@ -239,8 +473,82 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType return "unknown"; } -function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined { +function boundedToolArgument(value: unknown): string | undefined { + const normalized = typeof value === "string" ? value.trim().replace(/\s+/gu, " ") : ""; + if (!normalized) return undefined; + return normalized.length <= 48 ? normalized : `${normalized.slice(0, 47)}…`; +} + +function normalizedMcpToolName(value: string): string { + return ( + value + .split(/__|[./:]/u) + .at(-1) + ?.trim() ?? value.trim() + ); +} + +function computerUseToolTitle( + item: Extract, + presentation: McpToolPresentation, +): string | undefined { + if (normalizeItemType(item.server) !== "computer use") return undefined; + if (item.status === "failed") return undefined; + const tool = normalizeItemType(normalizedMcpToolName(item.tool)).replace(/ /gu, "_"); + const inProgress = item.status === "inProgress"; + const args = asUnknownRecord(item.arguments); + const appName = + (presentation.toolSource?.kind === "computer" && presentation.toolSource.name !== "Computer Use" + ? presentation.toolSource.name + : undefined) ?? + normalizedDisplayName(args?.appName) ?? + normalizedDisplayName(args?.application) ?? + normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined); + const withApp = (label: string) => (appName ? `${label} in ${appName}` : label); + switch (tool) { + case "list_apps": + return inProgress ? "Listing apps" : "Listed apps"; + case "click": + return withApp(inProgress ? "Clicking" : "Clicked"); + case "drag": + return withApp(inProgress ? "Dragging" : "Dragged"); + case "get_app_state": + case "get_state": + return appName + ? `${inProgress ? "Looking at" : "Looked at"} ${appName}` + : inProgress + ? "Looking at the screen" + : "Looked at the screen"; + case "perform_accessibility_action": + case "perform_secondary_action": + return inProgress ? "Performing accessibility action" : "Performed accessibility action"; + case "press_key": + return withApp(inProgress ? "Pressing key" : "Pressed key"); + case "scroll": { + const direction = boundedToolArgument(args?.direction)?.toLowerCase(); + return withApp(`${inProgress ? "Scrolling" : "Scrolled"}${direction ? ` ${direction}` : ""}`); + } + case "set_value": + return withApp(inProgress ? "Setting value" : "Set value"); + case "type_text": + return withApp(inProgress ? "Typing text" : "Typed text"); + default: + return undefined; + } +} + +function itemTitle( + itemType: CanonicalItemType, + item?: CodexLifecycleItem, + presentation: McpToolPresentation = {}, +): string | undefined { if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { + if (normalizedMcpToolName(item.tool) === "js") { + const intentTitle = normalizeMcpIntentTitle(asUnknownRecord(item.arguments)?.title); + if (intentTitle) return intentTitle; + } + const computerUseTitle = computerUseToolTitle(item, presentation); + if (computerUseTitle) return computerUseTitle; return `${item.server} · ${item.tool}`; } switch (itemType) { @@ -481,6 +789,8 @@ function mapItemLifecycle( } const detail = itemDetail(itemType, item); + const toolPresentation = item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}; + const title = itemTitle(itemType, item, toolPresentation); const status = lifecycle === "item.started" ? "inProgress" @@ -496,8 +806,9 @@ function mapItemLifecycle( payload: { itemType, ...(status ? { status } : {}), - ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}), + ...(title ? { title } : {}), ...(detail ? { detail } : {}), + ...toolPresentation, ...(event.payload !== undefined ? { data: event.payload } : {}), }, }; diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts index 6ea76d401ac4..e492e3f2070c 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts @@ -52,12 +52,10 @@ class FakeGeminiCliManager extends GeminiCliServerManager { } as unknown as ProviderSession; }); - public sendTurnImpl = vi.fn( - async (threadId: ThreadId): Promise => ({ - threadId, - turnId: asTurnId(`turn-${threadId}`), - }), - ); + public sendTurnImpl = vi.fn(async (threadId: ThreadId): Promise => ({ + threadId, + turnId: asTurnId(`turn-${threadId}`), + })); public interruptTurnImpl = vi.fn(async (): Promise => undefined); public respondToRequestImpl = vi.fn(async (): Promise => undefined); diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index 395508a8fff9..bb51c194127f 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -39,12 +39,10 @@ class FakeKiloManager extends KiloServerManager { } as unknown as ProviderSession; }); - public sendTurnImpl = vi.fn( - async (threadId: ThreadId): Promise => ({ - threadId, - turnId: asTurnId(`turn-${threadId}`), - }), - ); + public sendTurnImpl = vi.fn(async (threadId: ThreadId): Promise => ({ + threadId, + turnId: asTurnId(`turn-${threadId}`), + })); override startSession(input: { threadId: ThreadId }): Promise { return this.startSessionImpl(input.threadId); diff --git a/apps/server/src/provider/Layers/KiloProvider.ts b/apps/server/src/provider/Layers/KiloProvider.ts index af83ac59767f..ab38e693d702 100644 --- a/apps/server/src/provider/Layers/KiloProvider.ts +++ b/apps/server/src/provider/Layers/KiloProvider.ts @@ -100,9 +100,8 @@ const discoverKiloModels = ( }).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, - Effect.map( - (result): ReadonlyArray => - Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : [], + Effect.map((result): ReadonlyArray => + Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : [], ), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 6579ea6fa9c9..89de71b7fca1 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -191,20 +191,18 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { ): Effect.Effect => Effect.void, ); - const stopSession = vi.fn( - (threadId: ThreadId): Effect.Effect => - Effect.sync(() => { - sessions.delete(threadId); - }), + const stopSession = vi.fn((threadId: ThreadId): Effect.Effect => + Effect.sync(() => { + sessions.delete(threadId); + }), ); - const listSessions = vi.fn( - (): Effect.Effect> => - Effect.sync(() => Array.from(sessions.values())), + const listSessions = vi.fn((): Effect.Effect> => + Effect.sync(() => Array.from(sessions.values())), ); - const hasSession = vi.fn( - (threadId: ThreadId): Effect.Effect => Effect.succeed(sessions.has(threadId)), + const hasSession = vi.fn((threadId: ThreadId): Effect.Effect => + Effect.succeed(sessions.has(threadId)), ); const readThread = vi.fn( @@ -238,11 +236,10 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { Effect.succeed({ feedbackId: `feedback-${input.threadId}` }), ); - const stopAll = vi.fn( - (): Effect.Effect => - Effect.sync(() => { - sessions.clear(); - }), + const stopAll = vi.fn((): Effect.Effect => + Effect.sync(() => { + sessions.clear(); + }), ); const adapter: ProviderAdapterShape = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 3c114dd83d87..4ac4401ffe27 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -263,12 +263,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { concurrency: "unbounded", }, ).pipe( - Effect.map( - (verifiedProviders): VerifiedProviderRefresh => ({ - providers, - verifiedProviders, - }), - ), + Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ + providers, + verifiedProviders, + })), Effect.catchCause((cause) => Effect.logWarning("Provider post-update version verification failed", { provider, diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 0062e58a4c87..8461e57d5685 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -157,23 +157,21 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - (pullRequest): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), - body: pullRequest.body, - changedFiles: 0, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - reviewers: pullRequest.reviewers, - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: false }, - viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, - autoMergeEnabled: pullRequest.autoMergeEnabled, - ...(pullRequest.autoMergeMethod === undefined - ? {} - : { autoMergeMethod: pullRequest.autoMergeMethod }), - }), - ), + Effect.map((pullRequest): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles: 0, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + reviewers: pullRequest.reviewers, + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: false }, + viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + autoMergeEnabled: pullRequest.autoMergeEnabled, + ...(pullRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: pullRequest.autoMergeMethod }), + })), ), getChangeRequestActivity: (input) => @@ -187,15 +185,13 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => ({ comments: [], truncated: true })), ) ).pipe( - Effect.map( - (conversation): ProviderChangeRequestActivity => ({ - comments: conversation.comments, - commentCount: conversation.comments.length, - commentsTruncated: conversation.truncated, - reviewThreads: [], - commits: [], - }), - ), + Effect.map((conversation): ProviderChangeRequestActivity => ({ + comments: conversation.comments, + commentCount: conversation.comments.length, + commentsTruncated: conversation.truncated, + reviewThreads: [], + commits: [], + })), ), ), ), diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 70c6184ada0a..47d41eeee6d9 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -203,17 +203,15 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ - comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commentCount: comments.comments.length + pullRequest.reviews.length, - commentsTruncated: comments.truncated, - reviewThreads: comments.threads, - commits, - }), - ), + Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ + comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentCount: comments.comments.length + pullRequest.reviews.length, + commentsTruncated: comments.truncated, + reviewThreads: comments.threads, + commits, + })), ); }, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 3db5c8884f30..1e6ca0ed43a6 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2726,7 +2726,12 @@ layer("GitHubPullRequestCli.layer", (it) => { // One request, because both answers hang off the same repository object. assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(callAt(0).args).toContain("number=7"); - expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + expect(access).toEqual({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); }), ); @@ -2889,7 +2894,12 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + expect(access).toEqual({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); }), ); @@ -3029,4 +3039,56 @@ layer("GitHubPullRequestCli.layer", (it) => { ]); }), ); + + it.effect("puts labels on by posting to the issue's own collection, all at once", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["bug", "size:XL"], + applied: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + const call = callAt(0); + expect(call.args).toEqual([ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/issues/7/labels", + "--input", + "-", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body. + expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] }); + }), + ); + + it.effect("takes labels off one at a time, naming each in the path encoded", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["good first issue", "area/web"], + applied: false, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue"); + expect(callAt(0).args).toContain("DELETE"); + expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index c418fd0d37e8..3dc41896037b 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -18,6 +18,7 @@ import { type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerKind, + type PullRequestLabelCandidateList, type PullRequestThreadCommentsResult, type PullRequestUpdateMethod, } from "@t3tools/contracts"; @@ -42,6 +43,9 @@ import { decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, + decodeLabelCandidatesJson, + buildLabelRequestJson, + LABEL_CANDIDATES_GRAPHQL_QUERY, decodeReviewDismissalsJson, decodeReviewThreadCommentsJson, decodeReviewThreadsJson, @@ -597,6 +601,24 @@ export class GitHubPullRequestCli extends Context.Service< readonly requested: boolean; }) => Effect.Effect; + /** The repository's labels, and which of them this pull request already wears. */ + readonly listLabelCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly setLabels: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly labels: ReadonlyArray; + /** False takes each label off; true adds each to whatever is already there. */ + readonly applied: boolean; + }) => Effect.Effect; + readonly runPullRequestAction: (input: { readonly cwd: string; readonly repository: string; @@ -1977,6 +1999,56 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid); }, + listLabelCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listLabelCandidates", + allowReserve: true, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: LABEL_CANDIDATES_GRAPHQL_QUERY, + decode: decodeLabelCandidatesJson, + }); + }, + + setLabels: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // A pull request is an issue to the labels API. Adding posts a list and leaves what was + // already there; taking off is one delete per label, since the endpoint names one in its + // path. The name goes into the path encoded, because a label may carry a space or a slash. + const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; + if (input.applied) { + return github + .execute({ + cwd: input.cwd, + args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"], + stdin: buildLabelRequestJson(input.labels), + }) + .pipe(Effect.asVoid); + } + return Effect.forEach( + input.labels, + (label) => + github.execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "DELETE", + "--hostname", + input.host, + `${issue}/${encodeURIComponent(label)}`, + ], + }), + { concurrency: 1, discard: true }, + ); + }, + runPullRequestAction: (input) => { if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 7e6016288af7..8fd0f09dd3ea 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -47,7 +47,14 @@ it.effect("uses one narrow read for a linked pull request summary", () => describe("gitHubViewerPermissions", () => { it("offers everything to a viewer who can write to the repository", () => { - expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ + expect( + gitHubViewerPermissions({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + }), + ).toEqual({ // Arming a merge for later is the merge, so it travels with it. actions: [ "merge", @@ -64,6 +71,7 @@ describe("gitHubViewerPermissions", () => { resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, + labels: true, }); }); @@ -71,7 +79,12 @@ describe("gitHubViewerPermissions", () => { // Every open-source pull request somebody else opened: GitHub says no to all five actions // and to resolving, and yes to commenting and to every verdict. expect( - gitHubViewerPermissions({ canWrite: false, canUpdate: false, didAuthor: false }), + gitHubViewerPermissions({ + canWrite: false, + canTriage: false, + canUpdate: false, + didAuthor: false, + }), ).toEqual({ actions: [], comment: true, @@ -79,11 +92,31 @@ describe("gitHubViewerPermissions", () => { verdicts: ["comment", "approve", "request-changes"], // Asking somebody else to review is the one thing read access never stretches to. requestReviewers: false, + labels: false, + }); + }); + + it("lets a triager label without letting them merge or ask for a review", () => { + const permissions = gitHubViewerPermissions({ + canWrite: false, + canTriage: true, + canUpdate: false, + didAuthor: false, }); + expect(permissions.labels).toBe(true); + expect(permissions.requestReviewers).toBe(false); + expect(permissions.actions).toEqual([]); }); it("keeps an author's own pull request theirs to close, with read access and no more", () => { - expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({ + expect( + gitHubViewerPermissions({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }), + ).toEqual({ // Merging is the one thing writing is needed for, now or later; the rest an author may do. actions: ["ready", "draft", "close", "reopen"], comment: true, @@ -91,6 +124,7 @@ describe("gitHubViewerPermissions", () => { // GitHub refuses an author's approval of their own change, so the page does not offer one. verdicts: ["comment"], requestReviewers: false, + labels: false, }); }); @@ -110,6 +144,7 @@ describe("gitHubViewerPermissions", () => { resolve: false, verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, + labels: false, }); expect(detail.workflowApprovalsRequired).toBeUndefined(); expect(detail.checks).toContainEqual({ @@ -158,7 +193,12 @@ describe("gitHubViewerPermissions", () => { mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }), + Effect.succeed({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: false, + }), }), ), ), @@ -254,7 +294,7 @@ describe("gitHubViewerPermissions", () => { mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -318,7 +358,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals", mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -365,7 +405,7 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -404,7 +444,7 @@ it.effect("propagates workflow discovery rate limits", () => mergeCapabilities: { merge: true, squash: true, rebase: true }, }), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -420,7 +460,8 @@ describe("getViewerPermissions", () => { Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ getPullRequestDetail: () => Effect.succeed(openDetail), getPullRequestBaseComparison: () => comparison, - getViewerAccess: () => Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }); it.effect("offers update-branch when the comparison grants it", () => @@ -466,7 +507,7 @@ describe("getViewerPermissions", () => { getViewerAccess: (input) => Effect.sync(() => { viewerAllowReserve = input.allowReserve; - return { canWrite: true, canUpdate: true, didAuthor: false }; + return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }; }), }), ), @@ -501,7 +542,7 @@ describe("getViewerPermissions", () => { }), ), getViewerAccess: () => - Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5288040de25f..e75ef3547c04 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + labels: true, }; /** @@ -93,6 +94,8 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, requestReviewers: access.canWrite, ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), + // Triage is the one role that labels without writing, which is what triage is for. + labels: access.canTriage, }; } @@ -141,14 +144,12 @@ function withWorkflowApprovals( } const approvalChecks = runs .filter((run) => !representedRunIds.has(run.id)) - .map( - (run): PullRequestCheck => ({ - name: run.name, - status: "action-required", - description: "A maintainer must approve this workflow before it can run.", - url: run.url, - }), - ); + .map((run): PullRequestCheck => ({ + name: run.name, + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: run.url, + })); return [ ...checks, ...approvalChecks, @@ -360,38 +361,34 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ - ...detail.pullRequest, - checks: withWorkflowApprovals( - detail.pullRequest.checks, - detail.workflowApprovals.runs, - detail.workflowApprovals.unavailable, - ), - ...(detail.workflowApprovals.unavailable - ? {} - : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), - reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ - login, - name: null, - avatarUrl: null, - })), - mergeCapabilities: repository.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...viewerAccess, - canUpdateBranch: detail.comparison?.viewerCanUpdate === true, - }), - baseComparison: - detail.comparison === null || detail.comparison.behindBy === null - ? "unknown" - : detail.comparison.behindBy > 0 - ? "behind" - : "up-to-date", - ...(detail.comparison?.behindBy == null - ? {} - : { behindBy: detail.comparison.behindBy }), + Effect.map(([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ...detail.pullRequest, + checks: withWorkflowApprovals( + detail.pullRequest.checks, + detail.workflowApprovals.runs, + detail.workflowApprovals.unavailable, + ), + ...(detail.workflowApprovals.unavailable + ? {} + : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), + reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: repository.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions({ + ...viewerAccess, + canUpdateBranch: detail.comparison?.viewerCanUpdate === true, }), - ), + baseComparison: + detail.comparison === null || detail.comparison.behindBy === null + ? "unknown" + : detail.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }), + })), ), getChangeRequestActivity: (input) => @@ -423,53 +420,51 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ - author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), - reviewers: reviewThreads.reviewers, - reactions: reviewThreads.reactions, - commits: (reviewThreads.commits.length > 0 - ? reviewThreads.commits - : pullRequest.commits - ).map((commit) => ({ - ...commit, - ...reviewThreads.commitStats.get(commit.oid), - authors: commit.authors?.map( - (author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author, - ), - })), - comments: [...pullRequest.comments, ...reviewThreads.comments] - .map((comment) => ({ - ...comment, - // GitHub keeps the dismissal reason on the timeline event, not on the review, - // so a dismissed review with nothing visible of its own reads its words from - // there. "Visible" and not "empty": bot reviews often carry only an HTML - // marker comment, which markdown renders as nothing. - body: - comment.kind === "review" && - comment.reviewState?.toUpperCase() === "DISMISSED" && - rendersEmpty(comment.body) - ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) - : comment.body, - author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), - // A comment out of `gh pr view --json` carries none of its own: that read - // reports no reaction at all, so they arrive from the GraphQL page by node id. - reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], - })) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two - // are always whole and only the thread walk can stop short of the host. - commentCount: pullRequest.comments.length + reviewThreads.commentCount, - commentsTruncated: reviewThreads.truncated, - reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), - })), + Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ + author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), + reviewers: reviewThreads.reviewers, + reactions: reviewThreads.reactions, + commits: (reviewThreads.commits.length > 0 + ? reviewThreads.commits + : pullRequest.commits + ).map((commit) => ({ + ...commit, + ...reviewThreads.commitStats.get(commit.oid), + authors: commit.authors?.map( + (author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author, + ), + })), + comments: [...pullRequest.comments, ...reviewThreads.comments] + .map((comment) => ({ + ...comment, + // GitHub keeps the dismissal reason on the timeline event, not on the review, + // so a dismissed review with nothing visible of its own reads its words from + // there. "Visible" and not "empty": bot reviews often carry only an HTML + // marker comment, which markdown renders as nothing. + body: + comment.kind === "review" && + comment.reviewState?.toUpperCase() === "DISMISSED" && + rendersEmpty(comment.body) + ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) + : comment.body, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + // A comment out of `gh pr view --json` carries none of its own: that read + // reports no reaction at all, so they arrive from the GraphQL page by node id. + reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two + // are always whole and only the thread walk can stop short of the host. + commentCount: pullRequest.comments.length + reviewThreads.commentCount, + commentsTruncated: reviewThreads.truncated, + reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), })), - }), - ), + })), + })), ), getReviewThreadComments: (input) => @@ -526,6 +521,21 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("setReviewerRequest"))), + listLabelCandidates: (input) => + cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), + + setLabels: (input) => + cli + .setLabels({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }) + .pipe(Effect.mapError(fail("setLabels"))), + runAction: (input) => cli .runPullRequestAction({ diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 701ef53b08ec..46fce2884279 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -147,24 +147,22 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - ([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ - ...mergeRequest, - mergeCapabilities, - viewerPermissions: gitLabViewerPermissions(mergeRequest), - // A GitLab too old to count the divergence says nothing here rather than "up to - // date": the banner is worth missing, and a wrong all-clear is not worth showing. - baseComparison: - mergeRequest.divergedCommits === undefined - ? "unknown" - : mergeRequest.divergedCommits > 0 - ? "behind" - : "up-to-date", - ...(mergeRequest.divergedCommits === undefined - ? {} - : { behindBy: mergeRequest.divergedCommits }), - }), - ), + Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + mergeCapabilities, + viewerPermissions: gitLabViewerPermissions(mergeRequest), + // A GitLab too old to count the divergence says nothing here rather than "up to + // date": the banner is worth missing, and a wrong all-clear is not worth showing. + baseComparison: + mergeRequest.divergedCommits === undefined + ? "unknown" + : mergeRequest.divergedCommits > 0 + ? "behind" + : "up-to-date", + ...(mergeRequest.divergedCommits === undefined + ? {} + : { behindBy: mergeRequest.divergedCommits }), + })), ), getChangeRequestActivity: (input) => @@ -189,28 +187,26 @@ export const make = Effect.gen(function* () { { concurrency: 4 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ - reactions: awards.reactions, - comments: notes.comments.map((comment) => ({ + Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ + reactions: awards.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + // GitLab reports no count of its own, so the walk's own total is the host's: the + // notes endpoint carries every comment on the merge request, including the ones + // written under a discussion, and it is read until GitLab runs out. + commentCount: notes.comments.length, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ ...comment, reactions: awards.reactionsByNoteId.get(comment.id) ?? [], })), - // GitLab reports no count of its own, so the walk's own total is the host's: the - // notes endpoint carries every comment on the merge request, including the ones - // written under a discussion, and it is read until GitLab runs out. - commentCount: notes.comments.length, - commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - })), - commits, - }), - ), + })), + commits, + })), ), // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 155bf64109ca..1459d7cec921 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -26,6 +26,7 @@ import type { PullRequestReviewVerdict, PullRequestReviewerCandidateList, PullRequestReviewerKind, + PullRequestLabelCandidateList, PullRequestState, PullRequestUpdateMethod, PullRequestViewerPermissions, @@ -469,6 +470,23 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * The repository's labels, with the ones already on the change request marked. Present with + * `setLabels` only where `capabilities.labels` is true; the service refuses both without it. + */ + readonly listLabelCandidates?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Puts labels on the change request, or takes them off. One call for both directions. */ + readonly setLabels?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly labels: ReadonlyArray; + readonly applied: boolean; + }, + ) => Effect.Effect; + /** Only called when `capabilities.review.reply` is true. */ readonly replyToThread: ( input: ProviderRepositoryRef & { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d688430bdf5e..0dc7a928c264 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, @@ -77,6 +81,27 @@ function changeRequest(number: number, updatedAt: string): ProviderChangeRequest }; } +function hostedChangeRequest(body: string, additions = 1) { + return { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body, + additions, + changedFiles: 2, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"] as const, + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"] as const, + requestReviewers: true, + }, + }; +} + function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, @@ -980,6 +1005,41 @@ it.effect("refuses an action the host never claimed it could run", () => }), ); +it.effect("publishes a successful merge for immediate settlement", () => + Effect.scoped( + Effect.gen(function* () { + const mergedAt = "2026-09-03T02:00:00.000Z"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + runAction: () => TestClock.setTime(Date.parse(mergedAt)), + }), + ], + }); + const merges = yield* service.subscribeMerges; + const observedMerge = yield* Stream.runHead(merges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* service.runAction({ + ...reference, + repository: " ACME/WEB ", + action: "merge", + mergeMethod: "merge", + }); + + assert.deepStrictEqual(Option.getOrThrow(yield* Fiber.join(observedMerge)), { + ...reference, + mergedAt, + }); + }), + ), +); + it.effect("refuses an action this viewer may not take, and says what access it takes", () => Effect.gen(function* () { let ran: string | null = null; @@ -2262,6 +2322,125 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch }), ); +it.effect("refuses a label change on a host that has not said it takes one", () => + Effect.gen(function* () { + let changed = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The method is there; the capability that would let it be called is not. + setLabels: () => { + changed = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + labels: ["bug"], + applied: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "cannot change the labels"); + assert.isFalse(changed); + }), +); + +it.effect("refuses a label change this viewer may not make, and says what access it takes", () => + Effect.gen(function* () { + let changed = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, labels: true }, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + labels: false, + }), + listLabelCandidates: () => Effect.die("must not be called"), + setLabels: () => { + changed = true; + return Effect.void; + }, + }), + ], + }); + + const listError = yield* Effect.flip( + service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), + ); + assert.include(listError.message, "You need triage access on this repository"); + + const error = yield* Effect.flip( + service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + labels: ["bug"], + applied: true, + }), + ); + assert.include(error.message, "You need triage access on this repository"); + assert.isFalse(changed); + }), +); + +it.effect("hands a label change to the host, and reads the labels back for the menu", () => + Effect.gen(function* () { + let received: { labels: ReadonlyArray; applied: boolean } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, labels: true }, + listLabelCandidates: () => + Effect.succeed({ + candidates: [{ name: "bug", color: null, description: null, isApplied: false }], + truncated: false, + }), + setLabels: (input) => { + received = { labels: input.labels, applied: input.applied }; + return Effect.void; + }, + }), + ], + }); + + const list = yield* service.labelCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + }); + assert.deepStrictEqual( + list.candidates.map((label) => label.name), + ["bug"], + ); + + yield* service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + labels: ["bug"], + applied: false, + }); + assert.deepStrictEqual(received, { labels: ["bug"], applied: false }); + }), +); + it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => Effect.gen(function* () { let hostCalls = 0; @@ -2934,7 +3113,7 @@ it.effect( }), ); -it.effect("shares linked summaries and only recovers transient failures for display reads", () => +it.effect("shares linked summaries and reuses them for display without asking the host again", () => Effect.gen(function* () { let calls = 0; let failing = false; @@ -2984,7 +3163,8 @@ it.effect("shares linked summaries and only recovers transient failures for disp const stale = yield* service.summary(reference); assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); - assert.strictEqual(calls, 3); + // Display reads keep the last title and state rather than asking the host again. + assert.strictEqual(calls, 2); yield* service.invalidate({ reference }); const invalidated = yield* Effect.flip(service.summary(reference)); @@ -2992,6 +3172,177 @@ it.effect("shares linked summaries and only recovers transient failures for disp }), ); +it.effect("answers a known pull request immediately while the host refreshes", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + let calls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.gen(function* () { + calls += 1; + if (calls > 1) yield* Deferred.await(gate); + return hostedChangeRequest("cached body", 4); + }), + }), + ], + }); + + const first = yield* service.detail(reference); + assert.strictEqual(first.body, "cached body"); + assert.strictEqual(first.additions, 4); + + yield* TestClock.adjust("16 seconds"); + const second = yield* service.detail(reference); + assert.strictEqual(second.body, "cached body"); + assert.strictEqual(second.additions, 4); + yield* Effect.yieldNow; + assert.strictEqual(calls, 2); + }), +); + +it.effect("does not ask the host again for a linked summary it already holds", () => + Effect.gen(function* () { + let calls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.sync(() => { + calls += 1; + return changeRequest(1, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + + const first = yield* service.summary(reference); + assert.strictEqual(first.title, "Change request 1"); + yield* TestClock.adjust("61 seconds"); + const second = yield* service.summary(reference); + assert.strictEqual(second.title, "Change request 1"); + assert.strictEqual(calls, 1); + }), +); + +it.effect("reuses an observed merged state for strict settlement reads", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("merged body", 4), + state: "merged", + updatedAt: "2026-07-03T00:00:00Z", + }), + getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), + }), + ], + }); + + yield* service.detail(reference); + + const summary = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(summary.state, "merged"); + assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); + }), +); + +it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + let detailCalls = 0; + let summaryTitle = "old title"; + let summaryState: "open" | "merged" = "open"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.gen(function* () { + detailCalls += 1; + if (detailCalls > 1) yield* Deferred.await(gate); + return hostedChangeRequest("old body", 4); + }), + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + title: summaryTitle, + state: summaryState, + }), + }), + ], + }); + + const first = yield* service.detail(reference); + assert.strictEqual(first.title, "Change request 1"); + + summaryTitle = "merged title"; + summaryState = "merged"; + yield* TestClock.adjust("61 seconds"); + const settled = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(settled.title, "merged title"); + assert.strictEqual(settled.state, "merged"); + + yield* TestClock.adjust("16 seconds"); + const stale = yield* service.detail(reference); + assert.strictEqual(stale.title, "Change request 1"); + yield* Effect.yieldNow; + + const display = yield* service.summary(reference); + assert.strictEqual(display.title, "merged title"); + assert.strictEqual(display.state, "merged"); + assert.strictEqual(detailCalls, 2); + }), +); + +it.effect("does not let a still-cached detail overwrite a fresher linked summary", () => + Effect.gen(function* () { + let summaryTitle = "old title"; + let summaryState: "open" | "merged" = "open"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)), + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + title: summaryTitle, + state: summaryState, + }), + }), + ], + }); + + const first = yield* service.detail(reference); + assert.strictEqual(first.title, "Change request 1"); + + summaryTitle = "merged title"; + summaryState = "merged"; + const settled = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(settled.state, "merged"); + + const cached = yield* service.detail(reference); + assert.strictEqual(cached.title, "Change request 1"); + yield* Effect.yieldNow; + + const display = yield* service.summary(reference); + assert.strictEqual(display.title, "merged title"); + assert.strictEqual(display.state, "merged"); + }), +); + it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => Effect.gen(function* () { let failing = false; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index ffa96af12f08..b27fff3534a7 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,11 +1,15 @@ import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -38,6 +42,8 @@ import { type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, + type PullRequestLabelCandidateList, + type PullRequestLabelChangeInput, type PullRequestSubmitReviewInput, type PullRequestSummary, type PullRequestThreadReplyInput, @@ -61,6 +67,10 @@ import { } from "./PullRequestProvider.ts"; import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; +export interface PullRequestMergeEvent extends PullRequestRef { + readonly mergedAt: string; +} + /** * Rows per repository when the client does not ask for a page size, and rows per slice when a * listing is carried on from a cursor. @@ -133,6 +143,11 @@ export class PullRequestService extends Context.Service< input: PullRequestRef, options?: { readonly recoverTransientFailure?: boolean }, ) => Effect.Effect; + readonly subscribeMerges: Effect.Effect< + Stream.Stream, + never, + Scope.Scope + >; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -170,6 +185,12 @@ export class PullRequestService extends Context.Service< readonly requestReviewers: ( input: PullRequestReviewerRequestInput, ) => Effect.Effect; + readonly labelCandidates: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly setLabels: ( + input: PullRequestLabelChangeInput, + ) => Effect.Effect; readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect; } >()("t3/pullRequest/PullRequestService") {} @@ -213,6 +234,7 @@ const ACTION_ACCESS_REFUSALS: Record = { * sentence is only ever the answer where a host said no. */ const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; +const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; /** A project this page can read: its remote is on a host with an implementation. */ interface SupportedProject { @@ -473,6 +495,10 @@ function withRateLimitBackoff( submitReview: interactive("submitReview", api.submitReview), listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates), setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest), + ...(api.listLabelCandidates === undefined + ? {} + : { listLabelCandidates: interactive("listLabelCandidates", api.listLabelCandidates) }), + ...(api.setLabels === undefined ? {} : { setLabels: interactive("setLabels", api.setLabels) }), replyToThread: interactive("replyToThread", api.replyToThread), setReaction: interactive("setReaction", api.setReaction), setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), @@ -504,6 +530,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string } export const make = Effect.gen(function* () { + const mergedPullRequests = yield* PubSub.sliding(64); const registry = yield* PullRequestProviderRegistry; const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -1022,21 +1049,19 @@ export const make = Effect.gen(function* () { }), // One unreachable repository must not blank the page. A host-level failure is // already reported through `providers`, so it degrades the same way here. - Effect.orElseSucceed( - (): RepositoryBatch => ({ - key, - entries: [], - errors: [ - { - projectId: project.project.id, - projectTitle: project.project.title, - message: `${project.repository} could not be read.`, - }, - ], - truncated: false, - nextCursor: null, - }), - ), + Effect.orElseSucceed((): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + })), ); } }; @@ -1217,20 +1242,18 @@ export const make = Effect.gen(function* () { : project.api.getChangeRequestSummary(providerInput); return read.pipe( Effect.mapError(toPullRequestError("summary")), - Effect.map( - (changeRequest): PullRequestSummary => ({ - provider: project.api.kind, - projectId: project.project.id, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - url: changeRequest.url, - state: changeRequest.state, - headBranch: changeRequest.headBranch, - baseBranch: changeRequest.baseBranch, - updatedAt: changeRequest.updatedAt, - }), - ), + Effect.map((changeRequest): PullRequestSummary => ({ + provider: project.api.kind, + projectId: project.project.id, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + updatedAt: changeRequest.updatedAt, + })), ); }), ); @@ -1252,55 +1275,53 @@ export const make = Effect.gen(function* () { ], { concurrency: 2 }, ).pipe( - Effect.map( - ([changeRequest, viewer]): PullRequestDetail => ({ - provider: project.api.kind, - capabilities: project.api.capabilities, - projectId: project.project.id, - projectTitle: project.project.title, - workspaceRoot: project.project.workspaceRoot, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - body: changeRequest.body, - url: changeRequest.url, - author: changeRequest.author, - state: changeRequest.state, - isDraft: changeRequest.isDraft, - mergeability: changeRequest.mergeability, - additions: changeRequest.additions, - deletions: changeRequest.deletions, - changedFiles: changeRequest.changedFiles, - headBranch: changeRequest.headBranch, - ...(changeRequest.headRepositoryNameWithOwner === undefined - ? {} - : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), - baseBranch: changeRequest.baseBranch, - createdAt: changeRequest.createdAt, - updatedAt: changeRequest.updatedAt, - mergedAt: changeRequest.mergedAt, - closedAt: changeRequest.closedAt, - reviewers: changeRequest.reviewers, - labels: changeRequest.labels, - checks: changeRequest.checks, - mergeCapabilities: changeRequest.mergeCapabilities, - viewerPermissions: changeRequest.viewerPermissions, - ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), - ...(changeRequest.baseComparison === undefined - ? {} - : { baseComparison: changeRequest.baseComparison }), - ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), - ...(changeRequest.autoMergeEnabled === undefined - ? {} - : { autoMergeEnabled: changeRequest.autoMergeEnabled }), - ...(changeRequest.autoMergeMethod === undefined - ? {} - : { autoMergeMethod: changeRequest.autoMergeMethod }), - ...(changeRequest.workflowApprovalsRequired === undefined - ? {} - : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), - }), - ), + Effect.map(([changeRequest, viewer]): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + ...(changeRequest.headRepositoryNameWithOwner === undefined + ? {} + : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + mergeCapabilities: changeRequest.mergeCapabilities, + viewerPermissions: changeRequest.viewerPermissions, + ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), + ...(changeRequest.baseComparison === undefined + ? {} + : { baseComparison: changeRequest.baseComparison }), + ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), + ...(changeRequest.autoMergeEnabled === undefined + ? {} + : { autoMergeEnabled: changeRequest.autoMergeEnabled }), + ...(changeRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: changeRequest.autoMergeMethod }), + ...(changeRequest.workflowApprovalsRequired === undefined + ? {} + : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), + })), ), ), ); @@ -1317,18 +1338,16 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError(toPullRequestError("activity")), - Effect.map( - (activity): PullRequestActivity => ({ - ...(activity.author === undefined ? {} : { author: activity.author }), - ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), - comments: activity.comments, - commentCount: activity.commentCount, - commentsTruncated: activity.commentsTruncated, - reviewThreads: activity.reviewThreads, - commits: activity.commits, - ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), - }), - ), + Effect.map((activity): PullRequestActivity => ({ + ...(activity.author === undefined ? {} : { author: activity.author }), + ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), + comments: activity.comments, + commentCount: activity.commentCount, + commentsTruncated: activity.commentsTruncated, + reviewThreads: activity.reviewThreads, + commits: activity.commits, + ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), + })), ), ), ); @@ -1405,9 +1424,9 @@ export const make = Effect.gen(function* () { }), ); - const runAction: PullRequestService["Service"]["runAction"] = (input) => + const runAction = (input: PullRequestActionInput): Effect.Effect => requireProject(input).pipe( - Effect.flatMap((project): Effect.Effect => { + Effect.flatMap((project): Effect.Effect => { // The surface hides what a host cannot do, and this refuses it as well: a request that // reached here anyway must not be handed to a provider that never claimed the action. if (!project.api.capabilities.actions.includes(input.action)) { @@ -1449,7 +1468,7 @@ export const make = Effect.gen(function* () { // have to say yes. The second is asked last, because it costs a request and the checks // above do not. return viewerPermissionsOf(project, input, "runAction").pipe( - Effect.flatMap((viewer): Effect.Effect => { + Effect.flatMap((viewer): Effect.Effect => { if (!viewer.actions.includes(input.action)) { return Effect.fail( new PullRequestOperationError({ @@ -1479,7 +1498,10 @@ export const make = Effect.gen(function* () { ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) - .pipe(Effect.mapError(toPullRequestError("runAction"))); + .pipe( + Effect.mapError(toPullRequestError("runAction")), + Effect.as(project.repository), + ); }), ); }), @@ -1843,6 +1865,77 @@ export const make = Effect.gen(function* () { }), ); + /** + * The labels, like the reviewer candidates, are wanted only by somebody about to change them, + * so the same permission guards the list and the change. + */ + const labelCandidates: PullRequestService["Service"]["labelCandidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const list = project.api.listLabelCandidates; + if (project.api.capabilities.labels !== true || list === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "labelCandidates", + detail: "This host cannot change the labels on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "labelCandidates").pipe( + Effect.flatMap( + (viewer): Effect.Effect => + viewer.labels === false + ? Effect.fail( + new PullRequestOperationError({ + operation: "labelCandidates", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : list({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("labelCandidates"))), + ), + ); + }), + ); + + const setLabels: PullRequestService["Service"]["setLabels"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const change = project.api.setLabels; + if (project.api.capabilities.labels !== true || change === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: "This host cannot change the labels on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "setLabels").pipe( + Effect.flatMap((viewer): Effect.Effect => + viewer.labels === false + ? Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : change({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }).pipe(Effect.mapError(toPullRequestError("setLabels"))), + ), + ); + }), + ); + /** * The line counts for rows already on the page, which the listing left out because on GitHub * they cost more than everything else on the row put together. @@ -2001,7 +2094,25 @@ export const make = Effect.gen(function* () { }, }), ); - return { read, record }; + /** + * A change request already read does not wait on the host again. `reuse` answers from + * what we hold and spends nothing — title, author, and state barely move, and a linked + * thread already names the change request. `revalidate` answers the same way and + * refreshes behind it, so line counts and the rest can change in place. + */ + const serveHeld = ( + key: string, + effect: Effect.Effect, + mode: "reuse" | "revalidate", + ) => { + const snapshot = held.get(key); + if (snapshot === undefined) return read(key, effect); + if (mode === "reuse") return Effect.succeed(snapshot.value); + return Effect.sync(() => runFork(Effect.ignore(read(key, effect)))).pipe( + Effect.as(snapshot.value), + ); + }; + return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld }; }; const lastGoodSummary = makeLastGoodRead(DETAIL_CACHE_CAPACITY); const lastGoodDetail = makeLastGoodRead(DETAIL_CACHE_CAPACITY); @@ -2057,9 +2168,13 @@ export const make = Effect.gen(function* () { const summary: PullRequestService["Service"]["summary"] = (input, options) => { const key = refCacheKey(input); const cached = Cache.get(summaryCache, key); - return options?.recoverTransientFailure === false - ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))) - : lastGoodSummary.read(key, cached); + if (options?.recoverTransientFailure !== false) { + return lastGoodSummary.serveHeld(key, cached, "reuse"); + } + const held = lastGoodSummary.peek(key); + return held?.state === "merged" + ? Effect.succeed(held) + : cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))); }; // Keys serialize positionally and parse back in the lookup, so the cache is the only holder @@ -2149,9 +2264,42 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); + const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({ + provider: detail.provider, + projectId: detail.projectId, + repository: detail.repository, + number: detail.number, + title: detail.title, + url: detail.url, + state: detail.state, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + updatedAt: detail.updatedAt, + }); + const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { + const current = lastGoodSummary.peek(key); + if (current === undefined) return true; + if (current.state === "merged" && next.state !== "merged") return false; + return next.updatedAt >= current.updatedAt; + }; const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); - return lastGoodDetail.read(key, Cache.get(detailCache, key)); + // Record the summary from a host or cache read, not the stale value + // `serveHeld` returns immediately. Skip the write when that read is older + // than a later strict summary — display reuse would otherwise keep the + // regression and never ask the host again. + return lastGoodDetail.serveHeld( + key, + Cache.get(detailCache, key).pipe( + Effect.tap((value) => { + const summary = summaryFromDetail(value); + return shouldReplaceHeldSummary(key, summary) + ? lastGoodSummary.record(key, summary) + : Effect.void; + }), + ), + "revalidate", + ); }; const activityCache = yield* Cache.makeWith( @@ -2264,17 +2412,35 @@ export const make = Effect.gen(function* () { }), ), ); + const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( + "PullRequestService.runActionAndInvalidate", + )(function* (input) { + const repository = yield* runAction(input); + bumpRefEpoch({ ...input, repository }); + listingsEpoch = ++epochCounter; + if (input.action === "merge") { + yield* PubSub.publish(mergedPullRequests, { + projectId: input.projectId, + repository, + number: input.number, + mergedAt: DateTime.formatIso(yield* DateTime.now), + }); + } + }); return PullRequestService.of({ list, listStats, summary, + subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), detail, activity, threadComments, diff, diffFileContents, - runAction: invalidatedByMutation(runAction), + runAction: runActionAndInvalidate, update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), updateComment: invalidatedByMutation(updateComment), @@ -2285,6 +2451,8 @@ export const make = Effect.gen(function* () { // The candidate list is deliberately read fresh per menu-open, so it stays uncached. reviewerCandidates, requestReviewers: invalidatedByMutation(requestReviewers), + labelCandidates, + setLabels: invalidatedByMutation(setLabels), invalidate, }); }); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 000dbada2045..f6f5957f5875 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -11,6 +11,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodeLabelCandidatesJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, decodeReviewThreadCommentsJson, @@ -832,7 +833,7 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true }); }); it("says no to a passer-by on a repository they can only read", () => { @@ -845,7 +846,7 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canUpdate: false, didAuthor: false }); + ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false }); }); it("reads silence as permission, but not as authorship", () => { @@ -854,10 +855,79 @@ describe("viewer permission decoding", () => { // and claiming it for someone who did not is how an author's own rules get handed out. expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ canWrite: false, + canTriage: false, canUpdate: true, didAuthor: false, }); }); + + it("reads triage as enough to label, and not enough to write", () => { + const access = expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "TRIAGE", + pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, + }), + ), + ); + expect(access.canTriage).toBe(true); + expect(access.canWrite).toBe(false); + }); +}); + +describe("label candidate decoding", () => { + const labelsJson = (input: { + readonly defined: ReadonlyArray>; + readonly applied?: ReadonlyArray; + readonly hasNextPage?: boolean; + }) => + JSON.stringify({ + data: { + repository: { + labels: { + pageInfo: { hasNextPage: input.hasNextPage ?? false }, + nodes: input.defined, + }, + pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } }, + }, + }, + }); + + it("marks the labels the pull request already wears", () => { + const list = expectSuccess( + decodeLabelCandidatesJson( + labelsJson({ + defined: [ + { name: "bug", color: "d73a4a", description: "Something is broken" }, + { name: "size:XL", color: "e4572e", description: null }, + ], + applied: ["size:XL"], + }), + ), + ); + expect(list.candidates).toEqual([ + { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false }, + { name: "size:XL", color: "e4572e", description: null, isApplied: true }, + ]); + expect(list.truncated).toBe(false); + }); + + it("keeps a worn label the repository no longer defines, so it can be taken off", () => { + const list = expectSuccess( + decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })), + ); + expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([ + ["legacy", true], + ["bug", false], + ]); + }); + + it("says so when the repository defines more labels than the read asked for", () => { + expect( + expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true }))) + .truncated, + ).toBe(true); + }); }); describe("review thread decoding", () => { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 9cc21b429390..7887a81617d6 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -24,6 +24,8 @@ import type { PullRequestReviewerCandidate, PullRequestReviewerCandidateList, PullRequestReviewerKind, + PullRequestLabelCandidate, + PullRequestLabelCandidateList, PullRequestState, PullRequestThreadComment, } from "@t3tools/contracts"; @@ -1337,18 +1339,16 @@ function toComments(raw: { readonly comments?: ReadonlyArray> | undefined; readonly reviews?: ReadonlyArray> | undefined; }): ReadonlyArray { - const issueComments = (raw.comments ?? []).map( - (comment): PullRequestComment => ({ - id: comment.id, - kind: "issue-comment", - author: toActor(comment.author), - body: comment.body ?? "", - createdAt: comment.createdAt, - url: trimmed(comment.url), - path: null, - reviewState: null, - }), - ); + const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + })); // A review with no body is kept only when its state is the event itself — an approval, a // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the // container for line comments, and those comments are read from the review threads, so @@ -1741,19 +1741,17 @@ export function reviewThreadConversation( threads: ReadonlyArray, ): ReadonlyArray { return threads.flatMap((thread) => - thread.comments.map( - (comment): PullRequestComment => ({ - id: comment.id, - kind: "review-comment", - author: comment.author, - body: comment.body, - createdAt: comment.createdAt, - url: comment.url, - path: thread.path, - reviewState: null, - reactions: comment.reactions ?? [], - }), - ), + thread.comments.map((comment): PullRequestComment => ({ + id: comment.id, + kind: "review-comment", + author: comment.author, + body: comment.body, + createdAt: comment.createdAt, + url: comment.url, + path: thread.path, + reviewState: null, + reactions: comment.reactions ?? [], + })), ); } @@ -1994,6 +1992,11 @@ function toCanWrite(viewerPermission: string | null | undefined): boolean { } } +/** Triage is the least role GitHub lets label a pull request; it is not a write. */ +function toCanTriage(viewerPermission: string | null | undefined): boolean { + return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); +} + export function decodeRepositoryAccessJson( raw: string, ): Result.Result { @@ -2222,6 +2225,99 @@ export function buildReviewerRequestJson( }); } +export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) { + pageInfo { hasNextPage } + nodes { name color description } + } + pullRequest(number: $number) { + labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } } + } + } +}`; + +const RawLabelCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + ...RawLabelSchema.fields, + description: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + ), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr( + Schema.Struct({ + labels: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })), + ), + }), + ), + }), + }), +}); + +const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema); + +/** + * The repository's labels, with the ones already on this pull request marked. A label the pull + * request wears that the repository no longer defines — deleted since, or past the page — leads + * the list anyway, because a label that cannot be seen cannot be taken off. + */ +export function decodeLabelCandidatesJson( + raw: string, +): Result.Result { + const decoded = decodeLabelCandidates(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + const applied = new Set( + (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null ? [] : [name]; + }), + ); + const candidates = new Map(); + for (const node of repository.labels?.nodes ?? []) { + const name = trimmed(node?.name); + if (name === null) continue; + candidates.set(name, { + name, + color: trimmed(node?.color), + description: trimmed(node?.description), + isApplied: applied.has(name), + }); + } + const missing = [...applied].filter((name) => !candidates.has(name)); + return Result.succeed({ + candidates: [ + ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })), + ...candidates.values(), + ], + truncated: repository.labels?.pageInfo?.hasNextPage === true, + }); +} + +/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ +const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) }); + +const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema)); + +export function buildLabelRequestJson(labels: ReadonlyArray): string { + return encodeLabelRequest({ labels }); +} + /** * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the * repository, the other two about this pull request in particular — which is why an author with @@ -2229,6 +2325,11 @@ export function buildReviewerRequestJson( */ export interface GitHubViewerAccess { readonly canWrite: boolean; + /** + * The viewer's role reaches triage, which is the least that may label. Everyone who can write + * can triage; a triager is the one role that can label without being able to merge. + */ + readonly canTriage: boolean; /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ readonly canUpdate: boolean; readonly didAuthor: boolean; @@ -2275,6 +2376,7 @@ export function decodeViewerPermissionsJson( const repository = decoded.success.data.repository; return Result.succeed({ canWrite: toCanWrite(repository.viewerPermission), + canTriage: toCanTriage(repository.viewerPermission), ...toPullRequestViewerFields(repository.pullRequest), }); } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 2465052a33c7..b38861c9877e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -358,58 +358,45 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ).toEqual([activeThreadId]); }); - it("signs the activity publish JWT and rejects tampering", async () => { - const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { - privateKeyEncoding: { format: "pem", type: "pkcs8" }, - publicKeyEncoding: { format: "pem", type: "spki" }, - }); - const payload = { - iss: "t3-env:env", - aud: "https://relay.example.test", - sub: "env", - jti: "nonce-1", - iat: 100, - exp: 200, - environmentId: state.environmentId, - threadId: state.threadId, - state, - } satisfies RelayAgentActivityPublishProofPayload; - const proof = await Effect.runPromise( - AgentAwarenessRelay.signRelayAgentActivityPublishProof({ + it.effect("signs the activity publish JWT and rejects tampering", () => + Effect.gen(function* () { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const payload = { + iss: "t3-env:env", + aud: "https://relay.example.test", + sub: "env", + jti: "nonce-1", + iat: 100, + exp: 200, + environmentId: state.environmentId, + threadId: state.threadId, + state, + } satisfies RelayAgentActivityPublishProofPayload; + const proof = yield* AgentAwarenessRelay.signRelayAgentActivityPublishProof({ privateKey: keyPair.privateKey, payload, - }), - ); - - await expect( - Effect.runPromise( - verifyRelayJwt({ - publicKey: keyPair.publicKey, - token: proof, - typ: RELAY_ACTIVITY_PUBLISH_TYP, - issuer: "t3-env:env", - audience: "https://relay.example.test", - nowEpochSeconds: 150, - }), - ), - ).resolves.toMatchObject({ jti: "nonce-1", state }); - await expect( - Effect.runPromise( + }); + const verify = (token: string) => verifyRelayJwt({ publicKey: keyPair.publicKey, - token: (() => { - const [header, body, signature = ""] = proof.split("."); - const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; - return `${header}.${body}.${corruptedSignature}`; - })(), + token, typ: RELAY_ACTIVITY_PUBLISH_TYP, issuer: "t3-env:env", audience: "https://relay.example.test", nowEpochSeconds: 150, - }), - ), - ).rejects.toBeDefined(); - }); + }); + + expect(yield* verify(proof)).toMatchObject({ jti: "nonce-1", state }); + + const [header, body, signature = ""] = proof.split("."); + const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; + const rejection = yield* Effect.flip(verify(`${header}.${body}.${corruptedSignature}`)); + expect(rejection).toBeDefined(); + }), + ); it.effect("keeps the orchestration listener armed until relay config is installed", () => Effect.scoped( @@ -557,10 +544,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Effect.scoped( Effect.gen(function* () { const originalFetch = globalThis.fetch; - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); const events = yield* Queue.unbounded(); - const fetchSeen = yield* Deferred.make(); + let resolveFetchSeen: (url: URL) => void = () => {}; + const fetchSeen = new Promise((resolve) => { + resolveFetchSeen = resolve; + }); const userSpans: Array = []; const productSpans: Array = []; const collectingTracer = (spans: Array) => @@ -648,7 +636,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ? input : (input as unknown as { readonly url: string }).url, ); - runFork(Deferred.succeed(fetchSeen, url)); + resolveFetchSeen(url); return Promise.resolve(Response.json({ ok: true, deliveries: [] })); }) as unknown as typeof fetch; yield* Effect.addFinalizer(() => @@ -707,7 +695,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { occurredAt: now, } as unknown as OrchestrationEvent); - const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); + const url = yield* Effect.promise(() => fetchSeen).pipe(Effect.timeout("2 seconds")); expect(url.origin).toBe("https://transport.example.test"); expect(productSpans).toContain("makePublishProof"); expect(userSpans).not.toContain("makePublishProof"); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 0e4b99f9f1eb..1297b3bdf765 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -518,13 +518,11 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") if (message.type === "desktopTelemetryHello") { return recordContact.pipe( Effect.andThen( - updateHealth( - (current): DesktopTelemetryReceiverHealth => ({ - ...current, - status: "healthy", - lastError: Option.none(), - }), - ), + updateHealth((current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "healthy", + lastError: Option.none(), + })), ), ); } @@ -549,22 +547,18 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); }), Effect.andThen( - updateHealth( - (current): DesktopTelemetryReceiverHealth => ({ - ...current, - status: "stopped", - lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message), - }), - ), + updateHealth((current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "stopped", + lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message), + })), ), Effect.catch((error) => - updateHealth( - (current): DesktopTelemetryReceiverHealth => ({ - ...current, - status: "degraded", - lastError: Option.some(error.message), - }), - ), + updateHealth((current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "degraded", + lastError: Option.some(error.message), + })), ), Effect.forkScoped, ); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts index 4dd7e721d474..4184854aa269 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -491,12 +491,10 @@ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(functi validateProcessIdentity, retry: nativeClient.retry.pipe( Effect.zip(Ref.get(state)), - Effect.map( - ([accepted, current]): ResourceTelemetryRetryResult => ({ - accepted, - snapshot: current.latest, - }), - ), + Effect.map(([accepted, current]): ResourceTelemetryRetryResult => ({ + accepted, + snapshot: current.latest, + })), ), }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 42a756213114..7b587aceeceb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -56,7 +56,6 @@ 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 ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; @@ -123,6 +122,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; +import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; @@ -398,7 +398,9 @@ const makeBrowserOtlpPayload = (spanName: string) => ({ close }) => Effect.promise(close), ); - const runtime = ManagedRuntime.make( + // The exporter's batch fiber is forked while the layer builds and ticks on + // a wall-clock interval, so the whole tracer runs on the live clock. + yield* Layer.build( OtlpTracer.layer({ url: collector.url, exportInterval: "10 millis", @@ -411,14 +413,13 @@ const makeBrowserOtlpPayload = (spanName: string) => }, }, }).pipe(Layer.provide(browserOtlpTracingLayer)), + ).pipe( + Effect.flatMap((tracing) => + Effect.void.pipe(Effect.withSpan(spanName), Effect.provideContext(tracing)), + ), + TestClock.withLive, ); - try { - yield* Effect.promise(() => runtime.runPromise(Effect.void.pipe(Effect.withSpan(spanName)))); - } finally { - yield* Effect.promise(() => runtime.dispose()); - } - const request = yield* Effect.raceFirst( Effect.promise(() => collector.firstRequest).pipe(Effect.orDie), Effect.sleep(Duration.seconds(1)).pipe( @@ -626,6 +627,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePaths.layer), Layer.provide(T3ProjectFileLoader.layer), ), + NativeAppIconResolver.layer, ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fb0a21b63db8..cd10b0e549b5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -68,6 +68,7 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -451,7 +452,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // keeps a single Live for all opencode consumers. Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(WorkspaceLayerLive), - Layer.provideMerge(ProjectFaviconResolverLayerLive), + Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index aee23fb8f2bb..44ee6c94bff2 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalDate:off // @effect-diagnostics globalTimers:off // This file is shipped as a standalone bundle and copied to a stable path by // `t3 service update`. Keep runtime imports limited to Node built-ins. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c213b92762a5..105f0f403d86 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2030,6 +2030,16 @@ const makeWsRpcLayer = ( pullRequests.requestReviewers(input), { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsLabelCandidates]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsLabelCandidates, + pullRequests.labelCandidates(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsSetLabels]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSetLabels, pullRequests.setLabels(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -2169,7 +2179,10 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.assetsCreateUrl, Effect.gen(function* () { - if (input.resource._tag === "attachment") { + if ( + input.resource._tag === "attachment" || + input.resource._tag === "native-app-icon" + ) { return yield* issueAssetUrl({ resource: input.resource }); } if (input.resource._tag === "project-favicon") { diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 5c642471404a..84ff979e4e89 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,5 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; -import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + type AssetUrlState, + assetUrlStateFromResult, + EMPTY_ASSET_URL_ATOM, + resolveAssetUrl, +} from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -9,58 +14,42 @@ import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; - -export type AssetUrlState = - | { readonly _tag: "Loading" } - | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; +export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; export function useAssetUrlState( - environmentId: EnvironmentId, - resource: AssetResource, + environmentId: EnvironmentId | null, + resource: AssetResource | null, ): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( - assetEnvironment.createUrl({ - environmentId, - input: { resource }, - }), + environmentId === null || resource === null + ? EMPTY_ASSET_URL_ATOM + : assetEnvironment.createUrl({ environmentId, input: { resource } }), + ); + return assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, ); - if (result._tag === "Failure") { - return { _tag: "Failure" }; - } - if (preparedConnection._tag === "None" || result._tag !== "Success") { - return { _tag: "Loading" }; - } - const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null - ? { _tag: "Failure" } - : { - _tag: "Success", - url, - ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), - }; } -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { const result = useAssetUrlState(environmentId, resource); - if (result._tag !== "Success") { - return null; - } - return result.url; + return result._tag === "Success" ? result.url : null; } -/** Re-mints an exact-file capability after a file change or an explicit retry. */ export function useAssetUrlRefresh( - environmentId: EnvironmentId, - resource: AssetResource, + environmentId: EnvironmentId | null, + resource: AssetResource | null, ): () => Promise { const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); return useCallback(async () => { + if (environmentId === null || resource === null) return; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); }, [environmentId, resource, refresh]); diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts new file mode 100644 index 000000000000..94f97001c96f --- /dev/null +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveLinkTarget } from "./browserLinkTarget"; + +const click = { metaKey: false, ctrlKey: false }; + +describe("resolveLinkTarget", () => { + it("keeps the system browser unless the user asked for in-app", () => { + expect( + resolveLinkTarget({ + url: "https://example.com/", + event: click, + preference: "system", + canOpenInApp: true, + }), + ).toBe("system"); + }); + + it("opens in-app when asked and the runtime can", () => { + expect( + resolveLinkTarget({ + url: "https://example.com/", + event: click, + preference: "app", + canOpenInApp: true, + }), + ).toBe("app"); + }); + + it("falls back to the system browser where there is no in-app browser", () => { + // The hosted web app and mobile have nowhere to open a tab, so the + // preference cannot be honoured there and the link still has to open. + expect( + resolveLinkTarget({ + url: "https://example.com/", + event: click, + preference: "app", + canOpenInApp: false, + }), + ).toBe("system"); + }); + + it("treats a modifier click as the way out of the in-app default", () => { + expect( + resolveLinkTarget({ + url: "https://example.com/", + event: { metaKey: true, ctrlKey: false }, + preference: "app", + canOpenInApp: true, + }), + ).toBe("system"); + expect( + resolveLinkTarget({ + url: "https://example.com/", + event: { metaKey: false, ctrlKey: true }, + preference: "app", + canOpenInApp: true, + }), + ).toBe("system"); + }); + + it("leaves non-web schemes to the shell", () => { + for (const url of ["mailto:someone@example.com", "vscode://file/x", "not a url"]) { + expect(resolveLinkTarget({ url, event: click, preference: "app", canOpenInApp: true })).toBe( + "system", + ); + } + }); +}); diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts new file mode 100644 index 000000000000..d03775572747 --- /dev/null +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -0,0 +1,67 @@ +/** + * Where a link clicked inside a thread should open. + * + * Settings → Integrations → Browser lets the user choose between the OS + * default browser and a tab in the in-app browser. This module turns that + * preference plus the click itself into one answer, so chat markdown and the + * terminal drawer make the same decision and offer the same escape hatch. + * + * @module browserLinkTarget + */ +import type { BrowserLinkTarget } from "@t3tools/contracts"; + +import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; + +export interface ResolveLinkTargetInput { + readonly url: string; + /** Cmd/Ctrl-click always goes to the system browser, whatever the default. */ + readonly event: { readonly metaKey: boolean; readonly ctrlKey: boolean }; + readonly preference: BrowserLinkTarget; + /** Whether this client has an in-app browser and a thread to open it beside. */ + readonly canOpenInApp: boolean; +} + +/** + * The target a click resolves to. "app" only comes back when the preference + * asks for it, the runtime can honour it, the URL is one the in-app browser + * can load, and the click carried no modifier — the modifier is the one-gesture + * way out when the default is in-app, mirroring how change-request links + * already treat it. + */ +export function resolveLinkTarget(input: ResolveLinkTargetInput): BrowserLinkTarget { + if (input.event.metaKey || input.event.ctrlKey) return "system"; + if (input.preference !== "app") return "system"; + if (!input.canOpenInApp) return "system"; + if (!isWebUrl(input.url)) return "system"; + return "app"; +} + +/** + * Only http(s) can load in the in-app browser. Anything else — mailto:, + * vscode://, a bare fragment — belongs to the shell whatever the preference. + */ +export function isWebUrl(url: string): boolean { + try { + const { protocol } = new URL(url); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + +/** + * The configured default, once client settings have actually loaded. Before + * hydration the snapshot is the schema default ("system"), so a link clicked + * in the first moments after launch would ignore a persisted "app" — opening + * is asynchronous anyway, so waiting costs nothing the user can see. + */ +export async function resolveBrowserLinkTargetPreference(): Promise { + await ensureClientSettingsHydrated(); + return getClientSettings().browserLinkTarget; +} + +/** Whether the in-app target is available at all in this client. */ +export function canOpenLinksInApp(hasThread: boolean): boolean { + return hasThread && isPreviewSupportedInRuntime(); +} diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts new file mode 100644 index 000000000000..0e9bf721f82d --- /dev/null +++ b/apps/web/src/browser/useOpenLink.ts @@ -0,0 +1,63 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { useCallback } from "react"; + +import { recordVisitForThread } from "~/browserHistoryStore"; +import { readLocalApi } from "~/localApi"; +import { previewEnvironment } from "~/state/preview"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { + canOpenLinksInApp, + resolveBrowserLinkTargetPreference, + resolveLinkTarget, +} from "./browserLinkTarget"; +import { openUrlInPreview } from "./openFileInPreview"; + +const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; + +/** + * Opens a URL where the "Open links in" setting says, for buttons that sit + * beside a thread but are not markdown anchors: CI check details, a pull + * request that has no project to open in the panel. Without a thread there is + * nowhere to put an in-app tab, so the link goes to the system browser. + * + * An in-app open that fails falls back to the system browser rather than + * dropping the click: the user asked for the link, and the setting only says + * where it should go first. The returned promise rejects only when that + * fallback fails too, the same way `shell.openExternal` does. + */ +export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( + url: string, + options?: { + readonly event?: { readonly metaKey: boolean; readonly ctrlKey: boolean }; + /** Thread to open beside when it is not the hook's own, e.g. a sidebar row's. */ + readonly threadRef?: ScopedThreadRef | undefined; + }, +) => Promise { + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + return useCallback( + async (url, options = {}) => { + const targetThreadRef = options.threadRef ?? threadRef; + const target = resolveLinkTarget({ + url, + event: options.event ?? NO_MODIFIER, + preference: await resolveBrowserLinkTargetPreference(), + canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)), + }); + if (target === "app" && targetThreadRef) { + const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview }); + if (isAtomCommandInterrupted(result)) return; + if (result._tag === "Success") { + recordVisitForThread(targetThreadRef, url); + return; + } + console.error(result.cause); + } + const api = readLocalApi(); + if (!api) throw new Error("Link opening is unavailable."); + await api.shell.openExternal(url); + }, + [openPreview, threadRef], + ); +} diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 8f849a6e7b39..db69fe96c80a 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -90,4 +90,17 @@ describe("clientPersistenceStorage", () => { expect(settings).not.toHaveProperty("chatWordWrap"); expect(settings).not.toHaveProperty("diffWordWrap"); }); + + it("keeps the diff layout across reloads and defaults it to stacked", async () => { + const testWindow = getTestWindow(); + const { readBrowserClientSettings, writeBrowserClientSettings } = + await import("./clientPersistenceStorage"); + + expect(readBrowserClientSettings()).toBeNull(); + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); + expect(readBrowserClientSettings()?.diffLayout).toBe("stacked"); + + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffLayout: "split" }); + expect(readBrowserClientSettings()?.diffLayout).toBe("split"); + }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 251b07688121..ff4bb76bf12a 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -430,26 +430,40 @@ describe("shouldShowComposerContextStrip", () => { hasActiveProject: true, isGitRepo: false, showEnvironmentIndicator: true, + hostsRestingComposerControls: false, }), ).toBe(true); }); - it("hides the strip when a non-Git project has no environment indicator", () => { + it("hides the strip when a non-Git project has nothing to show", () => { expect( shouldShowComposerContextStrip({ hasActiveProject: true, isGitRepo: false, showEnvironmentIndicator: false, + hostsRestingComposerControls: false, }), ).toBe(false); }); + it("keeps the strip for visible resting composer controls in a non-Git thread", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + hostsRestingComposerControls: true, + }), + ).toBe(true); + }); + it("shows Git controls without requiring an environment indicator", () => { expect( shouldShowComposerContextStrip({ hasActiveProject: true, isGitRepo: true, showEnvironmentIndicator: false, + hostsRestingComposerControls: false, }), ).toBe(true); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0a8e07d1958b..0577f5e8dd1f 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -11,6 +11,7 @@ export interface EnvironmentOption { projectId: ProjectId; label: string; isPrimary: boolean; + machine: EnvironmentMachineKind; } export const EnvMode = Schema.Literals(["local", "worktree"]); @@ -58,8 +59,27 @@ export function shouldShowComposerContextStrip(input: { hasActiveProject: boolean; isGitRepo: boolean; showEnvironmentIndicator: boolean; + /** A collapsed composer's controls currently fit in their measured strip host. */ + hostsRestingComposerControls: boolean; }): boolean { - return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); + return ( + input.hasActiveProject && + (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls) + ); +} + +// Labels collapse to icons when the strip's content no longer fits. A small +// hysteresis on the way back out keeps the boundary from flapping. +const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16; + +export function resolveContextStripLabelsCompact(input: { + compact: boolean; + neededWidth: number; + availableWidth: number; +}): boolean { + return input.compact + ? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX + : input.neededWidth > input.availableWidth; } export function resolveEnvModeLabel(mode: EnvMode): string { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b0b1440587ea..0496bef06ef6 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -2,21 +2,20 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, - CloudIcon, FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon, - MonitorIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; -import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, type EnvironmentOption, + resolveContextStripLabelsCompact, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveEffectiveEnvMode, @@ -41,6 +40,9 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { ComposerSurface } from "./chat/ComposerSurface"; +import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; +import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; +import { cn } from "~/lib/utils"; interface BranchToolbarProps { environmentId: EnvironmentId; @@ -58,6 +60,8 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + composerControlsHostRef?: (element: HTMLDivElement | null) => void; + contextStripVisible?: boolean; } interface MobileRunContextSelectorProps { @@ -105,12 +109,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; - const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + ) : ( @@ -119,15 +125,26 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ const triggerContent = ( <> {icon} - - {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + + + {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + ); if (isLocked) { return ( - + {triggerContent} ); @@ -137,7 +154,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ } - className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" + data-composer-context-control > {triggerContent} @@ -151,21 +169,18 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ value={environmentId} onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} > - {availableEnvironments.map((env) => { - const Icon = env.isPrimary ? MonitorIcon : CloudIcon; - return ( - - - - {env.label} - - - ); - })} + {availableEnvironments.map((env) => ( + + + + {env.label} + + + ))} @@ -224,7 +239,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ * the expanded width without remembered values that could go stale or latch * the strip compact. A small hysteresis keeps the boundary from flapping. */ -const COMPACT_EXPAND_HYSTERESIS_PX = 16; const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; @@ -253,10 +267,14 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { let counted = 0; for (const child of parent.children) { if (!(child instanceof HTMLElement)) continue; - if (child.offsetWidth <= 1) continue; - const position = getComputedStyle(child).position; + if (child.offsetWidth === 0) continue; + const style = getComputedStyle(child); + const position = style.position; if (position === "absolute" || position === "fixed") continue; - width += child.offsetWidth; + width += + child.offsetWidth + + (Number.parseFloat(style.marginInlineStart) || 0) + + (Number.parseFloat(style.marginInlineEnd) || 0); counted += 1; } return width + gap * Math.max(0, counted - 1); @@ -266,10 +284,23 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { let groups = 0; for (const child of current.children) { if (!(child instanceof HTMLElement)) continue; - const width = contentWidth(child); + // The host itself flexes into all remaining room. Reserve the natural + // width of the controls inside it, blocks in overflow included, so Git + // labels compact before squeezing out the model picker. Reserving only + // the visible controls would let the labels expand into room the + // composer just freed, shrink the host, and hide the controls again. + const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') + ? child.querySelector('[data-chat-composer-resting-controls="true"]') + : null; + const hostedMeasurement = hostedControls + ? measureRestingComposerControls(hostedControls) + : null; + const width = hostedMeasurement + ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) + : contentWidth(hostedControls ?? child); if (width <= 1) continue; - needed += width; groups += 1; + needed += width; } needed += stripGap * Math.max(0, groups - 1); for (const label of current.querySelectorAll("[data-composer-label]")) { @@ -290,9 +321,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { needed += Math.max(0, textWidth - label.clientWidth); } } - const nextOverflows = compact - ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX - : needed > available; + const nextOverflows = resolveContextStripLabelsCompact({ + compact, + neededWidth: needed, + availableWidth: available, + }); if (nextOverflows !== compact) { pendingControlRectsRef.current = new Map( Array.from(current.querySelectorAll(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( @@ -392,6 +425,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + composerControlsHostRef, + contextStripVisible = true, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -462,7 +497,6 @@ export const BranchToolbar = memo(function BranchToolbar({ activeEnvironment: activeEnvironmentOption, canPickEnvironment: showEnvironmentPicker, }); - const isMobile = useIsMobile(); const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); @@ -472,24 +506,40 @@ export const BranchToolbar = memo(function BranchToolbar({ - {isMobile && showGitControls ? ( - - ) : ( -
+ {showGitControls ? ( +
+ +
+ ) : null} + {showGitControls || showEnvironmentIndicator ? ( +
{showEnvironmentIndicator && availableEnvironments && ( <> ) : null}
- )} + ) : null} + + {composerControlsHostRef ? ( + // The host takes whatever the workspace and branch controls leave + // over, in both strip layouts, so a collapsed composer can show its + // model and mode controls wherever they fit. +
+ ) : null} {showGitControls ? ( - #{branchPr.number} + + + #{branchPr.number} + + {branchPrTooltip} @@ -763,7 +773,7 @@ export function BranchToolbarBranchSelector({ > } - className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" + className="min-w-0 max-w-full font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" disabled={isInitialBranchesLoadPending || isBranchActionPending} > diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 23589d62bd95..9e25579d67a1 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -51,7 +51,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( {activeWorktreePath ? ( @@ -90,7 +90,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index b5d5751a280b..6304e37cf88d 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,8 +1,8 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { CloudIcon, MonitorIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Select, SelectGroup, @@ -49,14 +49,13 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || onEnvironmentChange === undefined) { return ( - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + ( - {env.isPrimary ? ( - - ) : ( - - )} + {env.label} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b08377e36a21..75127ea124e8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -119,7 +119,7 @@ import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; -import { getClientSettings } from "../hooks/useSettings"; +import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -173,6 +173,7 @@ import { openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; +import { resolveLinkTarget } from "../browser/browserLinkTarget"; interface ChatMarkdownProps { text: string; @@ -1274,7 +1275,6 @@ function ChatMarkdownVideo(props: { readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; readonly onRetry?: (() => Promise) | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { return ( { - props.onImageExpand?.({ - images: [ - { - src, - name: props.alt || "video", - type: "video", - autoPlay: false, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - ...(props.actionsSource - ? { actionsSource: { ...props.actionsSource, src } } - : {}), - }, - ], - index: 0, - }); - } - : undefined - } /> ); } @@ -1378,7 +1357,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props style={props.style} mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} onRetry={refreshAssetUrl} - onImageExpand={props.onImageExpand} actionsSource={actionsSource} /> ); @@ -2143,6 +2121,10 @@ function ChatMarkdown({ event.clipboardData.setData("text/html", payload.html); }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + // Subscribed rather than read at click time: the anchor has to decide + // synchronously whether to intercept its `_blank`, and a subscription is what + // makes a persisted "app" apply once settings hydrate after launch. + const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const resolveThreadPullRequest = useCallback( (href: string): ThreadLinkedPullRequest | null => { if ( @@ -2495,9 +2477,35 @@ function ChatMarkdown({ } // A link to a change request in a workspace project opens beside the // conversation instead of in a browser: it is the thing being talked about, and - // the panel it opens offers the browser as one of its actions. Anything else is - // an ordinary link and keeps the `_blank` the shell already handles. - if (href) openChangeRequestLink(event, href); + // the panel it opens offers the browser as one of its actions. + if (!href || openChangeRequestLink(event, href)) return; + // Anything else follows the "Open links in" setting. The system browser + // keeps the `_blank` the shell already handles; the in-app browser needs + // the click intercepted here. A modifier click is the way out of the + // in-app default, so it is left to the shell too. + if ( + event.defaultPrevented || + resolveLinkTarget({ + url: href, + event, + preference: linkTargetPreference, + canOpenInApp: canOpenInPreview, + }) !== "app" + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + // The click was taken from the shell, so an in-app open that fails + // hands the link to the system browser instead of dropping it. + void openExternalLinkInPreview(href).then((result) => { + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target: href }, + result.cause, + ); + void readLocalApi()?.shell.openExternal(href); + }); }} onContextMenu={(event) => { if (!href || !faviconHost) return; @@ -2642,7 +2650,6 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} originalUrl={originalUrl} style={authoredSizeStyle} - onImageExpand={imageExpand} actionsSource={actionsSource} /> ); @@ -2735,6 +2742,7 @@ function ChatMarkdown({ inlineCodeFileLinkMetaByText, imageBaseDir, isStreaming, + linkTargetPreference, markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 172793bacb0c..39be0eedafe2 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -145,6 +145,25 @@ describe("ChatMarkdown workspace images", () => { expect(html).not.toContain("Image unavailable"); }); + it("loads a POSIX absolute path and file URI through a signed asset URL", () => { + const html = renderToStaticMarkup( + , + ); + + expect(testState.resources).toEqual([ + { _tag: "media-file", threadId: threadRef.threadId, path: "/tmp/embed-test/2.png" }, + { _tag: "media-file", threadId: threadRef.threadId, path: "/tmp/embed-test/5.png" }, + ]); + expect(html).not.toContain("Image unavailable"); + }); + it("normalizes a drive-absolute src in raw image HTML", () => { const html = render(String.raw`raw`); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9fe03c1c980f..6be48b94ed37 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -24,7 +24,6 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, - isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -130,14 +129,6 @@ describe("proactive panels", () => { }); }); -describe("isVideoPreviewRequestCurrent", () => { - it("rejects changed threads and replaced previews", () => { - expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); - expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); - expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); - }); -}); - describe("toolGroupConsumesUpwardNavigation", () => { class ScrollElement extends EventTarget { scrollTop = 0; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 087378706338..cccc0a8dfe88 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -399,15 +399,6 @@ export async function resolveFileAttachmentUrl(input: { return url; } -export function isVideoPreviewRequestCurrent( - requestThreadKey: string, - currentThreadKey: string, - requestId: number, - currentRequestId: number, -): boolean { - return requestThreadKey === currentThreadKey && requestId === currentRequestId; -} - export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 27af2aa5ed2d..5f5663f2cadb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -23,6 +23,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, ProviderDriverKind, + resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -63,6 +64,7 @@ import { Suspense, useCallback, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -135,7 +137,6 @@ import { type ChatMessage, isBrowserPreviewAttachment, isImageAttachment, - videoMimeType, type SessionPhase, type Thread, type TurnDiffSummary, @@ -265,7 +266,6 @@ import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; -import { linkedPullRequestDetailAtom } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -292,6 +292,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -300,10 +301,11 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; -import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; import { + type EnvironmentOption, resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, @@ -323,7 +325,6 @@ import { } from "./chat/ThreadErrorBanner"; import { resolveDisplayedThreadPr, - threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; @@ -375,7 +376,6 @@ import { deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, - isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, @@ -1493,6 +1493,9 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [restingComposerControlsHost, setRestingComposerControlsHost] = + useState(null); + const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = useCallback( (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => { const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; @@ -1509,19 +1512,13 @@ function ChatViewContent(props: ChatViewProps) { [composerRef], ); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); - const routeThreadKeyRef = useRef(routeThreadKey); - routeThreadKeyRef.current = routeThreadKey; - const videoPreviewRequestIdRef = useRef(0); - const cancelVideoPreviewRequest = useCallback(() => { - videoPreviewRequestIdRef.current += 1; - }, []); - const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); useEffect(() => { const item = expandedImage?.images[expandedImage.index]; - if (item?.type !== "video" || !item.src.startsWith("blob:")) return; - return () => revokeBlobPreviewUrl(item.src); + if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return; + const src = item.src; + return () => revokeBlobPreviewUrl(src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -1589,10 +1586,16 @@ function ChatViewContent(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); + const getTimelineScrollableNode = useCallback( + () => legendListRef.current?.getScrollableNode() ?? null, + [], + ); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + const composerOverlayHeightRef = useRef(0); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); + const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); @@ -1793,6 +1796,8 @@ function ChatViewContent(props: ChatViewProps) { ); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const sidebarPrRefreshKeyRef = useRef(null); + const threadPrRelinkKeysRef = useRef(new Map()); + const threadPrRelinkWriteRef = useRef(Promise.resolve()); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; const resolvePreviewRuntimeTabId = useMemo( @@ -2063,22 +2068,18 @@ function ChatViewContent(props: ChatViewProps) { (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, ); const seen = new Set(); - const envs: Array<{ - environmentId: EnvironmentId; - projectId: ProjectId; - label: string; - isPrimary: boolean; - }> = []; + const envs: EnvironmentOption[] = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; + const environment = environmentById.get(p.environmentId) ?? null; envs.push({ environmentId: p.environmentId, projectId: p.id, - label, + label: environment?.label ?? p.environmentId, isPrimary, + machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); } // Sort: primary first, then alphabetical @@ -2582,12 +2583,11 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { return () => { clearAttachmentPreviewHandoffs(); - cancelVideoPreviewRequest(); for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } }; - }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]); + }, [clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; @@ -2615,18 +2615,6 @@ function ChatViewContent(props: ChatViewProps) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } - const isVideo = videoMimeType(attachment) !== null; - const action = isVideo ? "play" : "download"; - const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; - const isCurrentRequest = () => - !isVideo || - isVideoPreviewRequestCurrent( - routeThreadKey, - routeThreadKeyRef.current, - videoPreviewRequestId, - videoPreviewRequestIdRef.current, - ); - if (isVideo) setOpeningVideoAttachmentId(attachment.id); try { const url = await resolveFileAttachmentUrl({ @@ -2635,30 +2623,19 @@ function ChatViewContent(props: ChatViewProps) { httpBaseUrl: connection.httpBaseUrl, createAssetUrl: createAttachmentAssetUrl, }); - if (!isCurrentRequest()) return; - if (isVideo) { - setExpandedImage({ - images: [{ src: url, name: attachment.name, type: "video" }], - index: 0, - }); - return; - } const anchor = document.createElement("a"); anchor.href = url; anchor.download = attachment.name; anchor.click(); } catch (error) { - if (!isCurrentRequest()) return; toastManager.add({ type: "error", - title: "Could not " + action + " " + attachment.name, + title: "Could not download " + attachment.name, description: error instanceof Error ? error.message : "The attachment is unavailable.", }); - } finally { - if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); } }, - [createAttachmentAssetUrl, environmentId, routeThreadKey], + [createAttachmentAssetUrl, environmentId], ); const openFileAttachment = useCallback( (attachment: ChatFileAttachment) => { @@ -3042,10 +3019,20 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + // Keep a hidden, off-flow strip mounted for existing threads so the composer + // can measure whether its relocated controls fit. The visible chrome remains + // content-driven: Git/environment context or controls that actually fit. + const mountComposerContextStrip = shouldShowComposerContextStrip({ + hasActiveProject: activeProject !== null, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + hostsRestingComposerControls: routeKind === "server", + }); const showComposerContextStrip = shouldShowComposerContextStrip({ hasActiveProject: activeProject !== null, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, + hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; @@ -3687,9 +3674,50 @@ function ChatViewContent(props: ChatViewProps) { ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const linkedThreadPullRequest = isServerThread + const persistedLinkedThreadPullRequest = isServerThread ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) : (activeThread?.linkedPullRequest ?? null); + const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest( + activeThreadRef?.environmentId ?? null, + persistedLinkedThreadPullRequest, + ); + const replacementLinkedThreadPullRequest = useMemo(() => { + const detected = gitStatusQuery.data?.pr; + const threadBranch = activeThread?.branch; + const projectId = activeProject?.id; + if ( + persistedLinkedThreadPullRequest === null || + (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" && + persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") || + gitStatusQuery.data?.refName !== threadBranch || + detected?.state !== "open" || + detected.headRef !== threadBranch || + projectId === undefined || + activeProjectRepository === null || + (persistedLinkedThreadPullRequest.projectId === projectId && + persistedLinkedThreadPullRequest.repository.toLowerCase() === + activeProjectRepository.toLowerCase() && + persistedLinkedThreadPullRequest.number === detected.number) + ) { + return null; + } + return { + projectId, + repository: activeProjectRepository, + number: detected.number, + url: detected.url, + }; + }, [ + activeProject?.id, + activeProjectRepository, + activeThread?.branch, + gitStatusQuery.data, + persistedLinkedThreadPullRequest, + persistedLinkedThreadPullRequestStatus?.pr.state, + ]); + const linkedThreadPullRequest = + replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest; const linkedThreadPullRequestKey = linkedThreadPullRequest ? JSON.stringify([ linkedThreadPullRequest.projectId, @@ -3697,7 +3725,6 @@ function ChatViewContent(props: ChatViewProps) { linkedThreadPullRequest.number, ]) : null; - const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( (number: number) => { @@ -3721,6 +3748,63 @@ function ChatViewContent(props: ChatViewProps) { supportsPullRequests, ], ); + useEffect(() => { + if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { + return; + } + if (replacementLinkedThreadPullRequest === null) { + threadPrRelinkKeysRef.current.delete(activeThreadKey); + return; + } + const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; + if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; + threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); + const openSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if ( + openSurface?.kind === "pull-request" && + persistedLinkedThreadPullRequest !== null && + openSurface.projectId === persistedLinkedThreadPullRequest.projectId && + openSurface.repository.toLowerCase() === + persistedLinkedThreadPullRequest.repository.toLowerCase() && + openSurface.number === persistedLinkedThreadPullRequest.number + ) { + useRightPanelStore + .getState() + .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest); + } + + threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { + if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; + const result = await updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + linkedPullRequest: replacementLinkedThreadPullRequest, + }, + }); + if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; + if (result._tag !== "Failure") return; + threadPrRelinkKeysRef.current.delete(activeThreadKey); + if (isAtomCommandInterrupted(result)) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to update the thread pull request", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + }); + }, [ + activeThreadKey, + activeThreadRef, + isServerThread, + persistedLinkedThreadPullRequest, + replacementLinkedThreadPullRequest, + updateThreadMetadata, + ]); const openProjectPullRequest = useCallback( (number: number) => { if ( @@ -4281,6 +4365,7 @@ function ChatViewContent(props: ChatViewProps) { const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); + const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); const timelineScrollModeRef = useRef("following-end"); // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd // re-pins on its own (independent of the refs), so the timeline needs a @@ -4378,6 +4463,38 @@ function ChatViewContent(props: ChatViewProps) { }, [composerOverlayHeight], ); + const pageScrollControllerRef = useRef | null>( + null, + ); + const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { + if (key === "PageUp" && timelineRealContentOverflowsViewport()) { + cancelTimelineLiveFollowForUserNavigation(); + } + }); + useEffect(() => { + const controller = createPageScrollController({ + getContainer: () => legendListRef.current?.getScrollableNode() ?? null, + getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, + onScrollStart: handlePageScrollStart, + }); + pageScrollControllerRef.current = controller; + + return () => { + controller.dispose(); + if (pageScrollControllerRef.current === controller) { + pageScrollControllerRef.current = null; + } + }; + }, [composerOverlayElement]); + const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => { + pageScrollControllerRef.current?.handleKeyDown(key); + }, []); + const onComposerPageScrollKeyUp = useCallback((key: string) => { + pageScrollControllerRef.current?.handleKeyUp(key); + }, []); + const onComposerPageScrollRelease = useCallback(() => { + pageScrollControllerRef.current?.releaseActiveKey(); + }, []); // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { @@ -4453,6 +4570,14 @@ function ChatViewContent(props: ChatViewProps) { // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { + if (event.deltaY > 0) { + timelineScrollIntentRef.current = "toward-end"; + if (isAtEndRef.current) { + composerRef.current?.restoreAfterTimelineReachedEnd(); + } + } else if (event.deltaY < 0) { + timelineScrollIntentRef.current = "away-from-end"; + } if ( event.deltaY < 0 && contentScrollsUp() && @@ -4519,10 +4644,16 @@ function ChatViewContent(props: ChatViewProps) { case "PageUp": case "Home": case "ArrowUp": + timelineScrollIntentRef.current = "away-from-end"; if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { handleManualNavigation(); } break; + case "PageDown": + case "End": + case "ArrowDown": + timelineScrollIntentRef.current = "toward-end"; + break; default: break; } @@ -4613,6 +4744,9 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { + if (timelineScrollIntentRef.current === "toward-end") { + composerRef.current?.restoreAfterTimelineReachedEnd(); + } timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); @@ -4685,6 +4819,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; + timelineScrollIntentRef.current = null; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); @@ -4747,10 +4882,8 @@ function ChatViewContent(props: ChatViewProps) { return []; }); resetLocalDispatch(); - cancelVideoPreviewRequest(); - setOpeningVideoAttachmentId(null); setExpandedImage(null); - }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]); + }, [draftId, resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); @@ -4816,18 +4949,25 @@ function ChatViewContent(props: ChatViewProps) { activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId ? activePlan.steps : null; + + const publishComposerOverlayHeight = useCallback((height: number) => { + const nextHeight = Math.ceil(height); + if (nextHeight <= 0) return; + const previousHeight = composerOverlayHeightRef.current; + if (previousHeight !== nextHeight) { + composerOverlayHeightRef.current = nextHeight; + setComposerOverlayHeight(nextHeight); + } + setScrollToEndClearance((currentClearance) => + currentClearance === nextHeight ? currentClearance : nextHeight, + ); + }, []); + useLayoutEffect(() => { if (!composerOverlayElement) return; const updateHeight = () => { - const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); - if (nextHeight <= 0) return; - setComposerOverlayHeight((currentHeight) => - currentHeight === nextHeight ? currentHeight : nextHeight, - ); - setScrollToEndClearance((currentClearance) => - currentClearance === nextHeight ? currentClearance : nextHeight, - ); + publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); }; updateHeight(); @@ -4838,61 +4978,40 @@ function ChatViewContent(props: ChatViewProps) { return () => { resizeObserver.disconnect(); }; - }, [composerOverlayElement]); - const linkedPullRequestStatus = useLinkedThreadPullRequest( - activeThreadRef?.environmentId ?? null, - linkedThreadPullRequest, - ); - const activeThreadPr = resolveDisplayedThreadPr({ - threadBranch: activeThread?.branch ?? null, - gitStatus: gitStatusQuery.data ?? null, - snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, - retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, - linkedPullRequest: linkedThreadPullRequest, - linkedPullRequestStatus, - }); + }, [composerOverlayElement, publishComposerOverlayHeight]); + const activeThreadPr = + replacementLinkedThreadPullRequest !== null + ? (gitStatusQuery.data?.pr ?? null) + : resolveDisplayedThreadPr({ + threadBranch: activeThread?.branch ?? null, + gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, + linkedPullRequest: linkedThreadPullRequest, + linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus, + }); const handlePullRequestTabStatusChange = useCallback( - (status: PullRequestTabStatus) => { - const source = threadPullRequestRefreshSource({ - panel: status, - thread: { - repository: threadRepository, - number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, - state: activeThreadPr?.state ?? null, - linked: linkedThreadPullRequest !== null, - }, - }); - if (source === null) { + (status: Pick) => { + if ( + threadRepository?.toLowerCase() !== status.repository.toLowerCase() || + activeThreadPr?.number !== status.number || + activeThreadPr.state === status.state + ) { sidebarPrRefreshKeyRef.current = null; return; } - const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; + const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`; if (sidebarPrRefreshKeyRef.current === refreshKey) return; sidebarPrRefreshKeyRef.current = refreshKey; - - if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { - appAtomRegistry.refresh( - linkedPullRequestDetailAtom({ - environmentId: activeThreadRef.environmentId, - input: { - projectId: linkedThreadPullRequest.projectId, - repository: linkedThreadPullRequest.repository, - number: linkedThreadPullRequest.number, - }, - }), - ); - return; - } - if (source === "vcs" && activeThreadRef && gitCwd !== null) { - void refreshVcsStatus({ - environmentId: activeThreadRef.environmentId, - input: { cwd: gitCwd }, - }).then(() => { - if (sidebarPrRefreshKeyRef.current === refreshKey) { - sidebarPrRefreshKeyRef.current = null; - } - }); - } + if (activeThreadRef === null || gitCwd === null) return; + void refreshVcsStatus({ + environmentId: activeThreadRef.environmentId, + input: { cwd: gitCwd }, + }).then(() => { + if (sidebarPrRefreshKeyRef.current === refreshKey) { + sidebarPrRefreshKeyRef.current = null; + } + }); }, [ activeThreadKey, @@ -4900,7 +5019,6 @@ function ChatViewContent(props: ChatViewProps) { activeThreadPr?.state, activeThreadRef, gitCwd, - linkedThreadPullRequest, refreshVcsStatus, threadRepository, ], @@ -5673,6 +5791,16 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "rightPanel.close") { + // Nothing open: leave the event alone so the shortcut keeps its + // native meaning (close window on desktop, close tab in a browser). + if (!activeRightPanelSurface) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); @@ -5761,6 +5889,7 @@ function ChatViewContent(props: ChatViewProps) { terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, + closeRightPanelSurface, requestCloseTerminal, requestClosePanelTerminal, createNewTerminal, @@ -7249,14 +7378,9 @@ function ChatViewContent(props: ChatViewProps) { } }; - const onExpandTimelineImage = useCallback( - (preview: ExpandedImagePreview) => { - cancelVideoPreviewRequest(); - setOpeningVideoAttachmentId(null); - setExpandedImage(preview); - }, - [cancelVideoPreviewRequest], - ); + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { + setExpandedImage(preview); + }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; @@ -7389,6 +7513,7 @@ function ChatViewContent(props: ChatViewProps) { ) : renderedRightPanelSurface?.kind === "agents" ? (
@@ -7772,7 +7913,7 @@ function ChatViewContent(props: ChatViewProps) { data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined} className="relative z-0" > - {showComposerContextStrip && ( + {mountComposerContextStrip && (
)} @@ -7984,7 +8127,7 @@ function ChatViewContent(props: ChatViewProps) { {expandedImage && ( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 391137787def..a96a051e21d6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -36,6 +36,7 @@ import { type SourceControlProviderKind, type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, + resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; @@ -48,7 +49,6 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, - ServerIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -145,6 +145,7 @@ import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; @@ -181,8 +182,10 @@ function projectFavicon(project: Project) { ); } @@ -738,6 +741,7 @@ function OpenCommandPaletteDialog(props: { : isLocal ? `${environment.label} (Local)` : environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), }, ] as const; }), @@ -941,6 +945,16 @@ function OpenCommandPaletteDialog(props: { () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), [projects], ); + const projectIconByKey = useMemo( + () => + new Map( + projects.map( + (project) => + [`${project.environmentId}:${project.id}`, project.projectIcon ?? null] as const, + ), + ), + [projects], + ); const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.title])), [projects], @@ -1109,12 +1123,17 @@ function OpenCommandPaletteDialog(props: { const location = projectEnvironmentLocationById.get(project.environmentId) ?? { kind: "remote", label: "Remote", + machine: "server" as const, }; return ( {location.kind === "remote" ? ( - + ) : null} {location.label} @@ -1172,6 +1191,9 @@ function OpenCommandPaletteDialog(props: { environmentId={thread.environmentId} projectCwd={projectCwdById.get(thread.projectId) ?? null} projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null} + projectIcon={ + projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null + } projectTitle={projectTitle ?? null} branch={thread.branch} worktreePath={thread.worktreePath} @@ -1211,6 +1233,7 @@ function OpenCommandPaletteDialog(props: { navigate, projectCwdById, projectFaviconPathById, + projectIconByKey, projectTitleById, providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index efec8a440fe6..1676b4f01e7d 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -83,9 +83,11 @@ import { } from "./composerInlineChip"; import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; +import { didComposerSelectionChangeVisibly } from "./composerSelection"; import { $consumeComposerCitationCommentRequest, $createComposerCitationNode, @@ -903,7 +905,9 @@ interface ComposerPromptEditorProps { skills: ReadonlyArray; disabled: boolean; placeholder: string; + containerClassName?: string; className?: string; + placeholderClassName?: string; onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, @@ -912,10 +916,14 @@ interface ComposerPromptEditorProps { cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => void; + onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; + onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; + onPageScrollKeyUp?: (key: string) => void; + onPageScrollRelease?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } @@ -1553,16 +1561,24 @@ function ComposerPromptEditorInner({ skills, disabled, placeholder, + containerClassName, className, + placeholderClassName, onRemoveTerminalContext, onChange, + onVisibleSelectionChange, onCommandKeyDown, + onPageScrollKeyDown, + onPageScrollKeyUp, + onPageScrollRelease, onPaste, editorRef, }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); + const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); + const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const skillsSignature = skillSignature(skills); @@ -1571,9 +1587,10 @@ function ComposerPromptEditorInner({ const snapshotRef = useRef({ value, cursor: initialCursor, - expandedCursor: expandCollapsedComposerCursor(value, initialCursor), + expandedCursor: initialExpandedCursor, terminalContextIds: terminalContexts.map((context) => context.id), }); + const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); const isApplyingControlledUpdateRef = useRef(false); const citationCommentRequestRef = useRef(null); const [openCitationComment, setOpenCitationComment] = @@ -1598,6 +1615,10 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { + onVisibleSelectionChangeRef.current = onVisibleSelectionChange; + }, [onVisibleSelectionChange]); + useLayoutEffect(() => { skillMetadataRef.current = skillMetadataByName(skills); }, [skills]); @@ -1636,12 +1657,17 @@ function ComposerPromptEditorInner({ return; } + const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); snapshotRef.current = { value, cursor: normalizedCursor, - expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + expandedCursor: normalizedExpandedCursor, terminalContextIds: terminalContexts.map((context) => context.id), }; + selectionRangeRef.current = { + start: normalizedExpandedCursor, + end: normalizedExpandedCursor, + }; terminalContextsSignatureRef.current = terminalContextsSignature; skillsSignatureRef.current = skillsSignature; @@ -1693,6 +1719,10 @@ function ComposerPromptEditorInner({ expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, }; + selectionRangeRef.current = { + start: snapshotRef.current.expandedCursor, + end: snapshotRef.current.expandedCursor, + }; onChangeRef.current( snapshotRef.current.value, boundedCursor, @@ -1726,6 +1756,7 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); + const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); const terminalContextIds = collectTerminalContextIds($getRoot()); snapshot = { value: nextValue, @@ -1733,6 +1764,10 @@ function ComposerPromptEditorInner({ expandedCursor: nextExpandedCursor, terminalContextIds, }; + selectionRangeRef.current = selectionRange ?? { + start: nextExpandedCursor, + end: nextExpandedCursor, + }; }); snapshotRef.current = snapshot; return snapshot; @@ -1781,18 +1816,28 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); + const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); + const previousSelectionRange = selectionRangeRef.current; + selectionRangeRef.current = nextSelectionRange ?? { + start: nextExpandedCursor, + end: nextExpandedCursor, + }; const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; - if ( + const snapshotChanged = !( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && previousSnapshot.terminalContextIds.length === terminalContextIds.length && previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) - ) { + ); + if (isApplyingControlledUpdateRef.current) { return; } - if (isApplyingControlledUpdateRef.current) { + if (!snapshotChanged) { + if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { + onVisibleSelectionChangeRef.current?.(); + } return; } snapshotRef.current = { @@ -1817,7 +1862,12 @@ function ComposerPromptEditorInner({ return ( -
+
} + onKeyDown={(event) => { + if ( + event.key === "Control" || + event.key === "Meta" || + event.key === "Alt" || + event.key === "Shift" + ) { + onPageScrollRelease?.(); + } + + if (event.key !== "PageUp" && event.key !== "PageDown") { + return; + } + + const pageScrollKey = getTimelinePageScrollKey({ + altKey: event.altKey, + clientHeight: event.currentTarget.clientHeight, + ctrlKey: event.ctrlKey, + defaultPrevented: event.defaultPrevented, + isComposing: event.nativeEvent.isComposing, + key: event.key, + keyCode: event.keyCode, + metaKey: event.metaKey, + scrollHeight: event.currentTarget.scrollHeight, + scrollTop: event.currentTarget.scrollTop, + shiftKey: event.shiftKey, + }); + if (!pageScrollKey) { + onPageScrollRelease?.(); + return; + } + if (!onPageScrollKeyDown) { + return; + } + + event.preventDefault(); + onPageScrollKeyDown(pageScrollKey); + }} + onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} + onBlur={onPageScrollRelease} onPaste={onPaste} /> } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) @@ -1864,10 +1959,16 @@ export function ComposerPromptEditor({ skills, disabled, placeholder, + containerClassName, className, + placeholderClassName, onRemoveTerminalContext, onChange, + onVisibleSelectionChange, onCommandKeyDown, + onPageScrollKeyDown, + onPageScrollKeyUp, + onPageScrollRelease, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -1907,12 +2008,18 @@ export function ComposerPromptEditor({ skills={skills} disabled={disabled} placeholder={placeholder} + {...(containerClassName ? { containerClassName } : {})} onRemoveTerminalContext={onRemoveTerminalContext} onChange={onChange} + {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})} onPaste={onPaste} editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} + {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} + {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})} + {...(onPageScrollRelease ? { onPageScrollRelease } : {})} {...(className ? { className } : {})} + {...(placeholderClassName ? { placeholderClassName } : {})} /> ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index acdc54bb2f4c..c773617a7cf2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -15,12 +15,14 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + FolderTreeIcon, PilcrowIcon, RefreshCwIcon, Rows3Icon, SearchIcon, TextWrapIcon, } from "lucide-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { type DraftId } from "../composerDraftStore"; @@ -28,6 +30,7 @@ import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; +import { useLocalStorage } from "../hooks/useLocalStorage"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffContentVersion, @@ -44,12 +47,14 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; +import { DiffFileTree } from "./diffs/DiffFileTree"; +import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; @@ -82,6 +87,7 @@ import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; +const DIFF_FILE_TREE_STORAGE_KEY = "t3code.diffFileTreeOpen"; interface CollapsedDiffFilesState { readonly scopeKey: string | null; @@ -108,10 +114,15 @@ export default function DiffPanel({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); - const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode); - const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode); + const diffLayout = settings.diffLayout; + const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( + DIFF_FILE_TREE_STORAGE_KEY, + false, + Schema.Boolean, + ); const [baseRefQuery, setBaseRefQuery] = useState(""); const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ scopeKey: null, @@ -431,6 +442,7 @@ export default function DiffPanel({ const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]); + const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]); const selectedDiffFileKey = selectedFilePath ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null) : null; @@ -440,6 +452,29 @@ export default function DiffPanel({ codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); + // Held as state so the scroll runs after a collapsed file has been drawn open again; scrolling + // in the same tick would land on the folded header's position. + const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null); + useEffect(() => { + if (treeReveal === null) return; + codeViewRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" }); + }, [treeReveal]); + const revealDiffFile = useCallback( + (filePath: string) => { + const file = codeViewFiles.find((candidate) => candidate.filePath === filePath); + if (!file) return; + if (file.collapsed) { + setCollapsedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + next.delete(file.fileKey); + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + } + setTreeReveal((current) => ({ fileKey: file.fileKey, id: (current?.id ?? 0) + 1 })); + }, + [codeViewFiles, collapseScopeKey], + ); + const openDiffFile = useCallback( (filePath: string) => { openDiffFilePrimaryAction({ @@ -768,11 +803,11 @@ export default function DiffPanel({ { const next = value[0]; if (next === "stacked" || next === "split") { - setDiffRenderMode(next); + updateClientSettings({ diffLayout: next }); } }} > @@ -825,6 +860,26 @@ export default function DiffPanel({ {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + {codeViewFiles.length > 0 && ( + + setFileTreeOpen(Boolean(pressed))} + /> + } + > + + + + {fileTreeOpen ? "Hide file tree" : "Show file tree"} + + + )}
); @@ -878,97 +933,114 @@ export default function DiffPanel({
) ) : renderablePatch.kind === "files" ? ( -
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - for (const node of composedPath) { - if (!(node instanceof HTMLElement)) continue; - // Header controls keep their own actions. In particular, the chevron must - // not also trigger the row handler or the two toggles cancel each other. - if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { +
+
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // Header controls keep their own actions. In particular, the chevron must + // not also trigger the row handler or the two toggles cancel each other. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + } + const title = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const filePath = title?.textContent?.trim(); + // The filename remains the explicit "open in editor" affordance. + if (filePath) { + openDiffFile(filePath); return; } - } - const title = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-title"), - ); - const filePath = title?.textContent?.trim(); - // The filename remains the explicit "open in editor" affordance. - if (filePath) { - openDiffFile(filePath); - return; - } - const header = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), - ); - const headerFilePath = header?.querySelector("[data-title]")?.textContent?.trim(); - if (!headerFilePath) return; - const file = codeViewFiles.find( - (candidate) => candidate.filePath === headerFilePath, - ); - if (file) toggleDiffFileCollapsed(file.fileKey); - }} - > - ( - - )} - renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { - const filePath = resolveFileDiffPath(fileDiff); - return ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - - )} - - - {collapsed ? "Expand diff" : "Collapse diff"} - - + const header = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), ); + const headerFilePath = header + ?.querySelector("[data-title]") + ?.textContent?.trim(); + if (!headerFilePath) return; + const file = codeViewFiles.find( + (candidate) => candidate.filePath === headerFilePath, + ); + if (file) toggleDiffFileCollapsed(file.fileKey); }} - options={{ - diffStyle: diffRenderMode === "split" ? "split" : "unified", - lineDiffType: "none", - overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), - preferredHighlighter: PREFERRED_HIGHLIGHTER, - themeType: resolvedTheme as DiffThemeType, - stickyHeaders: true, - ...(currentLoadDiffFiles ? { loadDiffFiles } : {}), - }} - /> + > + ( + + )} + renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { + const filePath = resolveFileDiffPath(fileDiff); + return ( + + { + event.stopPropagation(); + toggleDiffFileCollapsed(fileKey); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + ); + }} + options={{ + diffStyle: diffLayout === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, + themeType: resolvedTheme as DiffThemeType, + stickyHeaders: true, + ...(currentLoadDiffFiles ? { loadDiffFiles } : {}), + }} + /> +
+ {fileTreeOpen ? ( + + ) : null}
) : (
diff --git a/apps/web/src/components/EnvironmentMachineIcon.tsx b/apps/web/src/components/EnvironmentMachineIcon.tsx new file mode 100644 index 000000000000..31b7a953a3a4 --- /dev/null +++ b/apps/web/src/components/EnvironmentMachineIcon.tsx @@ -0,0 +1,75 @@ +import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react"; +import type { FunctionComponent, SVGProps } from "react"; + +// Lucide has no Apple desktops, so these two are drawn to its grammar (24 +// unit grid, 2 unit stroke, round joins) and share its prop surface so callers +// can swap freely. +function LucideLike(props: SVGProps) { + return ( + + ); +} + +/** A Mac mini: squat rounded slab with a front-edge LED. */ +export function MacMiniIcon(props: SVGProps) { + return ( + + + + + ); +} + +/** A Mac Studio: the same slab twice as tall, ports along the front foot. */ +export function MacStudioIcon(props: SVGProps) { + return ( + + + + + ); +} + +const ICON_BY_KIND: Record> = { + server: ServerIcon, + cloud: CloudIcon, + desktop: MonitorIcon, + laptop: LaptopIcon, + "mac-mini": MacMiniIcon, + "mac-studio": MacStudioIcon, +}; + +export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { + server: "Server", + cloud: "Cloud VM", + desktop: "Desktop", + laptop: "Laptop", + "mac-mini": "Mac mini", + "mac-studio": "Mac Studio", +}; + +export function environmentMachineIcon( + kind: EnvironmentMachineKind, +): FunctionComponent { + return ICON_BY_KIND[kind]; +} + +export function EnvironmentMachineIcon({ + kind, + ...props +}: LucideProps & { readonly kind: EnvironmentMachineKind }) { + const Icon = ICON_BY_KIND[kind]; + return ; +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1c75476b96d6..341444324e73 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -32,7 +32,6 @@ import { ChevronDownIcon, CloudDownloadIcon, CloudUploadIcon, - ExternalLinkIcon, GitBranchPlusIcon, GitCommitIcon, InfoIcon, @@ -97,9 +96,9 @@ import { vcsEnvironment } from "~/state/vcs"; import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink"; +import { useOpenLink } from "~/browser/useOpenLink"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { gitCwd: string | null; @@ -384,10 +383,13 @@ interface PublishRepositoryDialogProps { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; + /** Thread the dialog was opened from, so the new repository can open beside it. */ + readonly threadRef: ScopedThreadRef | null; readonly gitCwd: string; } function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { + const openLink = useOpenLink(props.threadRef); const navigate = useNavigate(); const sourceControlDiscovery = useEnvironmentQuery( props.environmentId === null @@ -911,12 +913,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { size="sm" className="w-full" onClick={() => { - const api = readLocalApi(); - if (!api) return; - void api.shell.openExternal(publishResult.repository.url); + void openLink(publishResult.repository.url).catch(() => undefined); }} > - Open on {publishProviderLabel} @@ -1004,6 +1003,7 @@ export default function GitActionsControl({ [activeThreadRef], ); const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); + const openLink = useOpenLink(activeThreadRef); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -1238,15 +1238,6 @@ export default function GitActionsControl({ onOpenPullRequest(openPr.number); return; } - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - data: threadToastData, - }); - return; - } const prUrl = openPr?.url ?? null; if (!prUrl) { toastManager.add({ @@ -1256,7 +1247,7 @@ export default function GitActionsControl({ }); return; } - void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { + void openLink(prUrl).catch((err: unknown) => { console.error(err); toastManager.add( stackedThreadToast({ @@ -1267,7 +1258,7 @@ export default function GitActionsControl({ }), ); }); - }, [gitStatusForActions, onOpenPullRequest, threadToastData]); + }, [gitStatusForActions, onOpenPullRequest, openLink, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ @@ -2010,6 +2001,7 @@ export default function GitActionsControl({ open={isPublishDialogOpen} onOpenChange={setIsPublishDialogOpen} environmentId={activeEnvironmentId} + threadRef={activeThreadRef} gitCwd={gitCwd} /> diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index a74ea1add3fa..3c1c22d59e31 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -22,6 +22,7 @@ import { ThreadWorktreeIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; @@ -48,6 +49,7 @@ import { type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, + resolveEnvironmentMachineKind, ThreadId, } from "@t3tools/contracts"; import { @@ -395,9 +397,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + // No primary (the hosted app) means every thread is remote, and the machine + // glyph is what tells the environments apart. + const isRemoteThread = thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; + const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the // user's own machine, so the cloud icon is misleading — label it "Local" and @@ -876,7 +880,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr /> } > - + {threadEnvironmentLabel} @@ -2339,11 +2346,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + + + {project.displayName} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bbeeda4bc7fb..557f4d722adc 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -1,6 +1,7 @@ import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentId } from "@t3tools/contracts"; +import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", @@ -52,6 +53,10 @@ vi.mock("react", async (importOriginal) => { }); vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); +vi.mock("lucide-react/dynamic", () => ({ + DynamicIcon: "dynamic-icon", + iconNames: ["alarm-clock", "folder-code"], +})); vi.mock("../assets/assetUrls", () => ({ useAssetUrlState: (_environmentId: unknown, resource: unknown) => { testState.lastResource = resource; @@ -86,6 +91,7 @@ function resolveImageComponent(): { const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace-test", + projectName: "workspace-test", }) as ReactElement; hooks.reset(); @@ -106,6 +112,62 @@ function renderImage( describe("ProjectFavicon", () => { beforeEach(() => { hooks.reset(); + testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; + }); + + it("shows a project-name emoji when no favicon exists", () => { + testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; + + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/analytics-db", + projectName: "analytics-db", + }) as ReactElement<{ readonly emoji?: string }>; + + expect(element.props.emoji).toBe("🗄️"); + }); + + it("chooses a deterministic semantic emoji", () => { + testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; + + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/agent-runtime", + projectName: "agent-runtime", + }) as ReactElement<{ readonly emoji?: string }>; + + expect(element.props.emoji).toBe("🤖"); + }); + + it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/test", + projectName: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + }) as ReactElement<{ + readonly children: ReactElement<{ + readonly children: ReactElement<{ readonly name: string; readonly className: string }>; + }>; + readonly className: string; + }>; + + expect(element.props.children.props.children.props.name).toBe("alarm-clock"); + expect(element.props.className).toContain("text-violet-600"); + expect(element.props.children.props.children.props.className).toContain("text-violet-600"); + }); + + it("renders a saved emoji ahead of an uploaded favicon", () => { + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace/test", + projectName: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "emoji", emoji: "🦄" }, + }) as ReactElement<{ readonly emoji: string }>; + + expect(element.props.emoji).toBe("🦄"); }); it("falls back when the displayed favicon fails without discarding a valid older image early", () => { @@ -134,6 +196,7 @@ describe("ProjectFavicon", () => { ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace-test", + projectName: "workspace-test", faviconPath: "brand/icon.svg", }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 619bbf370018..2ebc6e267443 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,29 +1,153 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { FolderIcon } from "lucide-react"; +import { + BotIcon, + BookOpenIcon, + BracesIcon, + CircuitBoardIcon, + CloudCogIcon, + Code2Icon, + DatabaseIcon, + FlaskConicalIcon, + FolderCodeIcon, + Gamepad2Icon, + Globe2Icon, + ImageIcon, + Layers3Icon, + MonitorIcon, + MusicIcon, + PackageIcon, + ServerIcon, + ShieldCheckIcon, + ShoppingBagIcon, + SmartphoneIcon, + TerminalIcon, + VideoIcon, +} from "lucide-react"; +import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useAssetUrlState } from "../assets/assetUrls"; +import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; +import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); +const DynamicIcon = lazy(() => + import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), +); + +function DynamicProjectIconFallback() { + return ; +} + +const PROJECT_ICONS: Record> = { + ai: BotIcon, + book: BookOpenIcon, + braces: BracesIcon, + circuit: CircuitBoardIcon, + cloud: CloudCogIcon, + code: Code2Icon, + database: DatabaseIcon, + desktop: MonitorIcon, + "folder-code": FolderCodeIcon, + game: Gamepad2Icon, + image: ImageIcon, + layers: Layers3Icon, + mobile: SmartphoneIcon, + music: MusicIcon, + package: PackageIcon, + security: ShieldCheckIcon, + server: ServerIcon, + shopping: ShoppingBagIcon, + terminal: TerminalIcon, + test: FlaskConicalIcon, + video: VideoIcon, + web: Globe2Icon, +}; + +const PROJECT_ICON_COLOR_BY_NAME: Record = { + ai: "violet", + book: "amber", + braces: "purple", + circuit: "teal", + cloud: "sky", + code: "blue", + database: "cyan", + desktop: "indigo", + "folder-code": "orange", + game: "emerald", + image: "pink", + layers: "fuchsia", + mobile: "lime", + music: "fuchsia", + package: "orange", + security: "teal", + server: "blue", + shopping: "rose", + terminal: "green", + test: "yellow", + video: "red", + web: "sky", +}; export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + projectName: string; faviconPath?: string | null | undefined; + projectIcon?: ProjectIconOverride | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { const state = useProjectFaviconAsset(input); const src = state._tag === "Success" ? state.url : null; - const FallbackIcon = input.fallbackIcon ?? FolderIcon; + if (input.projectIcon?.kind === "emoji") { + return ; + } + if (input.projectIcon?.kind === "lucide") { + const colorClassName = projectIconColorClassName(input.projectIcon.color); + const iconClassName = cn( + "inline-flex size-3.5 shrink-0 items-center justify-center", + colorClassName, + input.className, + ); + return ( + + ); + } + const automaticIconName = input.fallbackIcon + ? null + : selectProjectIcon(input.projectName, input.cwd); + const FallbackIcon = + input.fallbackIcon ?? + (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined); + const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined; + const fallbackColorClassName = + automaticIconName?.kind === "lucide" + ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon]) + : undefined; if (!src || isProjectFaviconFallbackUrl(src)) { - return ; + return ( + + ); } const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); @@ -35,6 +159,8 @@ export function ProjectFavicon(input: { src={src} className={input.className} fallbackIcon={FallbackIcon} + fallbackEmoji={fallbackEmoji} + fallbackColorClassName={fallbackColorClassName} /> ); } @@ -53,12 +179,31 @@ export function useProjectFaviconAsset(input: { function ProjectFaviconFallback({ className, + colorClassName, icon: Icon, + emoji, }: { readonly className?: string | undefined; - readonly icon: ComponentType<{ className?: string }>; + readonly colorClassName?: string | undefined; + readonly icon?: ComponentType<{ className?: string }> | undefined; + readonly emoji?: string | undefined; }) { - return ; + if (emoji) { + return ( + + ); + } + + if (!Icon) return null; + return ; } function ProjectFaviconImage({ @@ -66,11 +211,15 @@ function ProjectFaviconImage({ src, className, fallbackIcon: FallbackIcon, + fallbackEmoji, + fallbackColorClassName, }: { readonly cacheKey: string; readonly src: string; readonly className?: string | undefined; - readonly fallbackIcon: ComponentType<{ className?: string }>; + readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; + readonly fallbackEmoji?: string | undefined; + readonly fallbackColorClassName?: string | undefined; }) { const [displayedSrc, setDisplayedSrc] = useState( () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, @@ -86,7 +235,12 @@ function ProjectFaviconImage({ return ( <> {displayedSrc === null ? ( - + ) : null} {displayedSrc ? ( ) { - return renderToStaticMarkup( - {}} - onAddScript={async () => undefined as never} - onUpdateScript={async () => undefined as never} - onDeleteScript={async () => undefined as never} - />, - ); -} - -function buttonTag(html: string, ariaLabel: string) { - return html.match(new RegExp(`]*aria-label="${ariaLabel}"[^>]*>`))?.[0]; -} - -function expectResponsiveXsControl(markup: string | undefined) { - expect(markup).toBeDefined(); - expect(markup).toContain("h-7"); - expect(markup).toContain("gap-1"); - expect(markup).toContain("text-sm"); - expect(markup).toContain("sm:h-6"); - expect(markup).toContain("sm:text-xs"); - expect(markup).toContain("w-7"); - expect(markup).toContain("px-0"); - expect(markup).toContain("sm:w-6"); - expect(markup).toContain("@3xl/header-actions:w-auto!"); - expect(markup).toContain("@3xl/header-actions:px-[calc(--spacing(2)-1px)]"); -} - -describe("ProjectScriptsControl compact controls", () => { - it("keeps the primary Run control compact and expands it with its label", () => { - const html = renderControl([PRIMARY_SCRIPT]); - - expectResponsiveXsControl(buttonTag(html, "Run Dev")); - expect(html).toContain( - 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', - ); - }); - - it("keeps the standalone Add control compact and expands it with its label", () => { - const html = renderControl([]); - - expectResponsiveXsControl(buttonTag(html, "Add action")); - expect(html).toContain( - 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', - ); - }); -}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 93de8c1ea310..03e6af60c673 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -34,7 +34,13 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + resolveEnvironmentMachineKind, + type EnvironmentMachineKind, + type ProjectIconOverride, + type ScopedThreadRef, + type ThreadId, +} from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, @@ -48,11 +54,9 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, - MessageSquareIcon, PinIcon, PlusIcon, SearchIcon, - ServerIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -126,6 +130,7 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animatePinnedLayoutChanges, @@ -211,9 +216,9 @@ import { // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; -// Keep the v2 key so existing preferences survive the v2-to-default rename. -const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; -const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +// Fresh keys deliberately reset both shelves to collapsed for existing users. +const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; +const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -278,7 +283,9 @@ function SidebarV2ThreadTooltip({ projectTitle, projectCwd, projectFaviconPath, + projectIcon, environmentLabel, + environmentMachine, providerEntry, showInstanceBadge, modelInstanceId, @@ -291,7 +298,9 @@ function SidebarV2ThreadTooltip({ projectTitle: string | null; projectCwd: string | null; projectFaviconPath: string | null; + projectIcon: ProjectIconOverride | null; environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; providerEntry: ProviderInstanceEntry | null; showInstanceBadge: boolean; modelInstanceId: string; @@ -322,15 +331,20 @@ function SidebarV2ThreadTooltip({
{projectTitle}
) : null} {environmentLabel ? (
- +
{environmentLabel}
) : null} @@ -490,6 +504,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { projectTitle: string | null; projectCwd: string | null; projectFaviconPath: string | null; + projectIcon: ProjectIconOverride | null; isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; @@ -555,7 +570,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { @@ -600,6 +617,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; + projectIconByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; @@ -696,6 +714,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} projectCwd={props.projectCwdByKey.get(projectKey) ?? null} projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} + projectIcon={props.projectIconByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} onNavigate={props.onNavigateToDraft} onDiscard={handleDiscard} @@ -740,8 +759,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { jumpLabel: string | null; currentEnvironmentId: string | null; environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; projectCwd: string | null; projectFaviconPath: string | null; + projectIcon: ProjectIconOverride | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; @@ -968,8 +989,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + // The local environment is "this machine" and needs no marker; every other + // one gets its machine glyph. With no local environment (the hosted app) + // that is every thread, which is the point: the glyph is what tells rows on + // different machines apart. + const isRemote = thread.environmentId !== props.currentEnvironmentId; const detailsTooltip = ( {title} @@ -1438,7 +1465,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.projectTitle ? ( @@ -1595,7 +1624,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { > {isRemote ? ( - + ) : null} {driverKind ? ( @@ -1654,8 +1687,10 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { thread: SidebarThreadSummary; projectCwd: string | null; projectFaviconPath: string | null; + projectIcon: ProjectIconOverride | null; projectTitle: string | null; environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; providerEntryByInstanceId: ReadonlyMap; isHighlighted: boolean; isRouteActive: boolean; @@ -1736,9 +1771,10 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { {thread.title} @@ -1750,7 +1786,9 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { projectTitle={props.projectTitle} projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} + projectIcon={props.projectIcon} environmentLabel={props.environmentLabel} + environmentMachine={props.environmentMachine} providerEntry={providerEntry} showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} @@ -1918,6 +1956,19 @@ export default function SidebarV2() { ), [environments], ); + const environmentMachineById = useMemo( + () => + new Map( + environments.map( + (environment) => + [ + environment.environmentId, + resolveEnvironmentMachineKind(environment.serverConfig), + ] as const, + ), + ), + [environments], + ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -1984,6 +2035,13 @@ export default function SidebarV2() { ), [projects], ); + const projectIconByKey = useMemo( + () => + new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]), + ), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -2287,7 +2345,7 @@ export default function SidebarV2() { ); const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( SETTLED_SHELF_EXPANDED_KEY, - true, + false, Schema.Boolean, ); const toggleSettledShelf = useCallback( @@ -3620,12 +3678,16 @@ export default function SidebarV2() { } > {scopedProjectGroup ? ( - + + + ) : ( )} @@ -3678,7 +3740,9 @@ export default function SidebarV2() { ) : ( @@ -3762,12 +3826,19 @@ export default function SidebarV2() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } + projectIcon={ + projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, ) ?? null } environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} + environmentMachine={ + environmentMachineById.get(thread.environmentId) ?? "server" + } providerEntryByInstanceId={ providerEntriesByEnvironment.get(thread.environmentId) ?? EMPTY_PROVIDER_ENTRIES @@ -3866,6 +3937,9 @@ export default function SidebarV2() { } currentEnvironmentId={primaryEnvironmentId} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} + environmentMachine={ + environmentMachineById.get(thread.environmentId) ?? "server" + } projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } @@ -3874,6 +3948,10 @@ export default function SidebarV2() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } + projectIcon={ + projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, @@ -3917,6 +3995,7 @@ export default function SidebarV2() { projectDisplayNameByKey={projectDisplayNameByKey} projectCwdByKey={projectCwdByKey} projectFaviconPathByKey={projectFaviconPathByKey} + projectIconByKey={projectIconByKey} scopedProjectKeys={scopedProjectKeys} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 015b15c5ea04..cd5074518719 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -38,6 +38,7 @@ export function ThreadCommandSubtitle(props: { environmentId: EnvironmentId; projectCwd: string | null; projectFaviconPath?: string | null; + projectIcon?: import("@t3tools/contracts").ProjectIconOverride | null; projectTitle: string | null; branch: string | null; worktreePath: string | null; @@ -72,7 +73,9 @@ export function ThreadCommandSubtitle(props: { ) : null} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 078c3e97f5f5..31b94db36d4c 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -10,10 +10,10 @@ import { resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, - threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; +import { newestPullRequestSummary } from "../state/pullRequests"; function status(overrides: Partial = {}): VcsStatusResult { return { @@ -57,58 +57,44 @@ function snapshotFor( return { branch, pr, sourceControlProvider }; } -describe("threadPullRequestRefreshSource", () => { - const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const }; +function pullRequestSummary( + state: PullRequestSummary["state"], + updatedAt: string, +): PullRequestSummary { + return { + provider: "github", + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + state, + headBranch: "feature/current", + baseBranch: "main", + updatedAt, + }; +} - it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, - }), - ).toBe("vcs"); - }); +describe("shared pull request state", () => { + it("shows a panel-observed merge instead of an older sidebar summary", () => { + const open = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); + const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); - it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true }, - }), - ).toBe("linked-detail"); + expect(newestPullRequestSummary(open, merged)).toBe(merged); }); - it("refreshes when the sidebar has not resolved state yet", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false }, - }), - ).toBe("vcs"); - }); + it("never lets a stale open response regress a merged observation", () => { + const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); + const staleOpen = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); - it("does nothing once sidebar state matches or the panel shows another PR", () => { - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 42, state: "merged", linked: false }, - }), - ).toBeNull(); - expect( - threadPullRequestRefreshSource({ - panel, - thread: { repository: "pingdotgg/t3code", number: 41, state: "open", linked: false }, - }), - ).toBeNull(); + expect(newestPullRequestSummary(merged, staleOpen)).toBe(merged); }); - it("matches repository identity without case sensitivity", () => { - expect( - threadPullRequestRefreshSource({ - panel: { ...panel, repository: "PingDotGG/T3Code" }, - thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, - }), - ).toBe("vcs"); + it("accepts a newer open state after a closed pull request is reopened", () => { + const closed = pullRequestSummary("closed", "2026-09-03T01:00:00.000Z"); + const reopened = pullRequestSummary("open", "2026-09-03T01:01:00.000Z"); + + expect(newestPullRequestSummary(closed, reopened)).toBe(reopened); }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e879c78b9710..1ad0c3139fd8 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,15 +4,21 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; -import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts"; +import { + type EnvironmentId, + resolveEnvironmentMachineKind, + type ThreadLinkedPullRequest, + type VcsStatusResult, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; -import { linkedPullRequestDetailAtom } from "../state/pullRequests"; +import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; @@ -39,32 +45,6 @@ export interface TerminalStatusIndicator { export type ThreadPr = VcsStatusResult["pr"]; -export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs"; - -/** Refresh only when the panel has newer state for this thread's own pull request. */ -export function threadPullRequestRefreshSource(input: { - readonly panel: { - readonly repository: string; - readonly number: number; - readonly state: NonNullable["state"]; - }; - readonly thread: { - readonly repository: string | null; - readonly number: number | null; - readonly state: NonNullable["state"] | null; - readonly linked: boolean; - }; -}): ThreadPullRequestRefreshSource | null { - if ( - input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() || - input.thread.number !== input.panel.number || - input.thread.state === input.panel.state - ) { - return null; - } - return input.thread.linked ? "linked-detail" : "vcs"; -} - export interface LinkedThreadPullRequestStatus { readonly pr: NonNullable; readonly sourceControlProvider: NonNullable; @@ -74,7 +54,7 @@ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, ): LinkedThreadPullRequestStatus | null { - const detail = useEnvironmentQuery( + const queried = useEnvironmentQuery( environmentId === null || linkedPullRequest == null ? null : linkedPullRequestDetailAtom({ @@ -86,6 +66,7 @@ export function useLinkedThreadPullRequest( }, }), ).data; + const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); return useMemo( () => @@ -627,10 +608,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + // No primary (the hosted app) means every thread is remote, and the machine + // glyph is what tells the environments apart. + const isRemoteThread = thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; + const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); if (!terminalStatus && !isRemoteThread) { @@ -667,7 +650,10 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {threadEnvironmentLabel} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index bdcd16265480..1c64818c5794 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -728,10 +728,8 @@ export function TerminalViewport({ }; void openTerminalLinkInPreview({ url: text, - position: { x: event.clientX, y: event.clientY }, threadRef, openPreview, - localApi, fallbackToBrowser, }); return; diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 12715a558fe2..4afaefe3f489 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -23,6 +23,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; +import { composerFloatingLayerProps } from "./composerEventScope"; const CITATION_ACTION_BUTTON_CLASS_NAME = cn( COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, @@ -135,6 +136,7 @@ export function AssistantCitationChip({ {commentEditor.open ? ( , + onOverlayHeightChange: (height: number) => void, +) { + const elementRef = useRef(null); + const isCollapsedRef = useRef(isCollapsed); + const previousCollapsedRef = useRef(isCollapsed); + const previousHeightRef = useRef(null); + const previousContentOffsetsRef = useRef<{ + promptFromTop: number | null; + promptHeight: number | null; + actionFromBottom: number | null; + }>({ promptFromTop: null, promptHeight: null, actionFromBottom: null }); + const animationRef = useRef(null); + const animationTargetHeightRef = useRef(null); + const contentAnimationsRef = useRef([]); + const stateChangeAnimationsRef = useRef([]); + const pinnedOverlayRef = useRef(null); + const transitionCleanupTimeoutRef = useRef(null); + const transitionLayoutRequestRef = useRef(0); + const hasCompletedInitialLayoutRef = useRef(false); + + const clearOverlayPin = useCallback(() => { + // The overlay belongs to the chat view and outlives this composer, so it + // is remembered from pin time rather than re-resolved through a ref that + // React may already have detached during unmount. + const overlay = pinnedOverlayRef.current; + pinnedOverlayRef.current = null; + overlay?.style.removeProperty("height"); + overlay?.style.removeProperty("display"); + overlay?.style.removeProperty("flex-direction"); + overlay?.style.removeProperty("justify-content"); + }, []); + + const clearTransitionStyles = useCallback(() => { + const element = elementRef.current; + const footer = element?.querySelector('[data-chat-composer-footer="true"]'); + element?.style.removeProperty("overflow"); + element + ?.querySelector('[data-chat-composer-surface="true"]') + ?.style.removeProperty("height"); + footer?.style.removeProperty("position"); + footer?.style.removeProperty("top"); + footer?.style.removeProperty("bottom"); + footer?.style.removeProperty("left"); + footer?.style.removeProperty("right"); + footer?.style.removeProperty("height"); + clearOverlayPin(); + }, [clearOverlayPin]); + + isCollapsedRef.current = isCollapsed; + + const transitionToCurrentGeometry = useCallback( + (stateChanged: boolean) => { + const element = elementRef.current; + const surface = element?.querySelector('[data-chat-composer-surface="true"]'); + if (!element || !surface) return; + + const nextIsCollapsed = isCollapsedRef.current; + + const visibleTransitionElement = (selector: string) => + Array.from(element.querySelectorAll(selector)).find( + (candidate) => candidate.getClientRects().length > 0, + ) ?? null; + const prompt = visibleTransitionElement( + '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', + ); + const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); + const footer = element.querySelector('[data-chat-composer-footer="true"]'); + const interruptedAnimation = animationRef.current; + const interruptedPromptTop = interruptedAnimation + ? (prompt?.getBoundingClientRect().top ?? null) + : null; + const interruptedActionTop = interruptedAnimation + ? (action?.getBoundingClientRect().top ?? null) + : null; + const interruptedHeight = interruptedAnimation + ? element.getBoundingClientRect().height + : null; + const interruptedTargetHeight = animationTargetHeightRef.current; + const interruptedCurrentTime = + typeof interruptedAnimation?.currentTime === "number" + ? interruptedAnimation.currentTime + : null; + const interruptedDuration = interruptedAnimation?.effect?.getComputedTiming().duration; + if (transitionCleanupTimeoutRef.current !== null) { + window.clearTimeout(transitionCleanupTimeoutRef.current); + transitionCleanupTimeoutRef.current = null; + } + interruptedAnimation?.cancel(); + animationRef.current = null; + for (const animation of contentAnimationsRef.current) animation.cancel(); + contentAnimationsRef.current = []; + // The reveal and fade animations keep their own schedule across the + // body-resize re-entries that retarget the geometry mid-flight (every + // transition with a draft triggers one); cancelling them there would + // pop their subjects to full visibility at the start of the tween. + if (stateChanged) { + for (const animation of stateChangeAnimationsRef.current) animation.cancel(); + stateChangeAnimationsRef.current = []; + } + clearTransitionStyles(); + + const nextRect = element.getBoundingClientRect(); + const nextHeight = nextRect.height; + const nextPromptRect = prompt?.getBoundingClientRect() ?? null; + const nextPromptTop = nextPromptRect?.top ?? null; + const nextActionTop = action?.getBoundingClientRect().top ?? null; + const previousHeight = interruptedHeight ?? previousHeightRef.current; + const targetChanged = + interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; + const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + const shouldAnimate = shouldAnimateComposerRestingTransition({ + hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, + stateChanged, + hasInterruptedAnimation: interruptedHeight !== null, + }); + + if ( + shouldAnimate && + !prefersReducedMotion && + previousHeight !== null && + Math.abs(previousHeight - nextHeight) >= 0.5 + ) { + const remainingDuration = + typeof interruptedDuration === "number" && interruptedCurrentTime !== null + ? Math.max(1, interruptedDuration - interruptedCurrentTime) + : COMPOSER_RESTING_TRANSITION_DURATION_MS; + const duration = + interruptedHeight !== null && !targetChanged + ? remainingDuration + : COMPOSER_RESTING_TRANSITION_DURATION_MS; + element.style.overflow = "clip"; + surface.style.height = "100%"; + + // The chat view resize-observes the overlay to place the timeline + // inset, the scroll-to-end pill, and the mini player. Pinning the + // overlay at the destination height turns that feedback into one + // update instead of a ChatView re-render on every animation frame; + // bottom alignment keeps the animating surface glued to the overlay's + // stable bottom edge. The pin lasts only for the tween so later + // attachment, thread, font, and viewport changes remain natural. + const overlay = element.closest('[data-chat-composer-overlay="true"]'); + let pinnedOverlayHeight: number | null = null; + if (overlay) { + pinnedOverlayHeight = overlay.getBoundingClientRect().height; + overlay.style.height = `${String(pinnedOverlayHeight)}px`; + overlay.style.display = "flex"; + overlay.style.flexDirection = "column"; + overlay.style.justifyContent = "flex-end"; + pinnedOverlayRef.current = overlay; + } + + // Keep the footer attached to the stable bottom edge while the outer + // height changes. Its resting absolute layout otherwise spans the old + // height on collapse, while its expanded flow layout falls below the + // clipped surface on expansion. + if (footer) { + footer.style.position = "absolute"; + footer.style.top = "auto"; + footer.style.bottom = "1px"; + footer.style.height = "3rem"; + if (nextIsCollapsed) { + footer.style.left = "auto"; + footer.style.right = "1px"; + } else { + footer.style.left = "1px"; + footer.style.right = "1px"; + } + } + + const animation = element.animate( + [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], + { + duration, + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ); + animationRef.current = animation; + animationTargetHeightRef.current = nextHeight; + // Publish the destination overlay geometry in the same layout pass; + // ResizeObserver remains the fallback for non-transition changes. + if (pinnedOverlayHeight !== null) { + onOverlayHeightChange(pinnedOverlayHeight); + } + + const animatedRect = element.getBoundingClientRect(); + const previousPromptTop = + interruptedPromptTop ?? + (previousContentOffsetsRef.current.promptFromTop === null + ? null + : animatedRect.top + previousContentOffsetsRef.current.promptFromTop); + const previousActionTop = + interruptedActionTop ?? + (previousContentOffsetsRef.current.actionFromBottom === null + ? null + : animatedRect.bottom - previousContentOffsetsRef.current.actionFromBottom); + const contentAnimations: Animation[] = []; + const animateContentPosition = ( + content: HTMLElement | null, + previousTop: number | null, + ) => { + if (!content || previousTop === null) return; + const offset = previousTop - content.getBoundingClientRect().top; + if (Math.abs(offset) < 0.5) return; + contentAnimations.push( + content.animate( + [{ transform: `translateY(${String(offset)}px)` }, { transform: "none" }], + { + duration, + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ), + ); + }; + animateContentPosition(prompt, previousPromptTop); + animateContentPosition(action, previousActionTop); + contentAnimationsRef.current = contentAnimations; + + if (stateChanged) { + const stateChangeAnimations: Animation[] = []; + + // A prompt that gains lines on expansion would otherwise slide up + // from under the footer band as one block. Opening a bottom clip in + // step with the tween instead unfurls the extra lines beneath the + // rising first line, so no text crosses the returning controls. + const previousPromptHeight = previousContentOffsetsRef.current.promptHeight; + if ( + !nextIsCollapsed && + prompt && + nextPromptRect && + previousPromptHeight !== null && + nextPromptRect.height - previousPromptHeight >= 0.5 + ) { + const hiddenHeight = nextPromptRect.height - previousPromptHeight; + stateChangeAnimations.push( + prompt.animate( + [ + { clipPath: `inset(0 0 ${String(hiddenHeight)}px 0)` }, + { clipPath: "inset(0 0 0 0)" }, + ], + { + duration, + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ), + ); + } + + // The footer controls teleport between the composer footer and the + // context strip below it in a single commit. Fading the arriving + // cluster in along its direction of travel reads as one continuous + // move instead of a pop. Collapsing controls land in empty strip + // space and can appear immediately, but expanding controls return + // to the bottom row the prompt still occupies while the surface is + // short, so they stay hidden through the first half of the tween + // and fade in once the geometry has mostly settled. + const arrivingControls = nextIsCollapsed + ? restingControlsRef.current + : element.querySelector('[data-chat-composer-controls="left"]'); + if (arrivingControls) { + const drift = nextIsCollapsed + ? -COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX + : COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX; + stateChangeAnimations.push( + arrivingControls.animate( + [ + { opacity: 0, transform: `translateY(${String(drift)}px)` }, + { opacity: 1, transform: "none" }, + ], + { + duration: nextIsCollapsed ? duration : duration / 2, + delay: nextIsCollapsed ? 0 : duration / 2, + fill: "backwards", + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ), + ); + } + + const arrivingImagePreviews = nextIsCollapsed + ? Array.from( + element.querySelectorAll('[data-chat-composer-resting-images="true"]'), + ) + : Array.from( + element.querySelectorAll('[data-chat-composer-expanded-image="true"]'), + ); + for (const imagePreview of arrivingImagePreviews) { + stateChangeAnimations.push( + imagePreview.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: nextIsCollapsed ? duration : duration / 2, + delay: nextIsCollapsed ? 0 : duration / 2, + fill: "backwards", + easing: COMPOSER_RESTING_TRANSITION_EASING, + }), + ); + } + stateChangeAnimationsRef.current = stateChangeAnimations; + } + + const finishTransition = (cancelAnimations: boolean) => { + if (animationRef.current !== animation) return; + if (transitionCleanupTimeoutRef.current !== null) { + window.clearTimeout(transitionCleanupTimeoutRef.current); + transitionCleanupTimeoutRef.current = null; + } + if (cancelAnimations) { + animation.cancel(); + for (const contentAnimation of contentAnimationsRef.current) { + contentAnimation.cancel(); + } + for (const stateChangeAnimation of stateChangeAnimationsRef.current) { + stateChangeAnimation.cancel(); + } + } + animationRef.current = null; + animationTargetHeightRef.current = null; + contentAnimationsRef.current = []; + stateChangeAnimationsRef.current = []; + clearTransitionStyles(); + }; + void animation.finished.catch(() => undefined).then(() => finishTransition(false)); + // A suspended document timeline can leave `finished` pending while + // these measurement styles remain active. Wall-clock cleanup makes + // the natural layout the eventual source of truth in that case. + transitionCleanupTimeoutRef.current = window.setTimeout( + () => finishTransition(true), + duration + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS, + ); + } else { + animationTargetHeightRef.current = null; + } + + previousCollapsedRef.current = nextIsCollapsed; + previousHeightRef.current = nextHeight; + previousContentOffsetsRef.current = { + promptFromTop: nextPromptTop === null ? null : nextPromptTop - nextRect.top, + promptHeight: nextPromptRect?.height ?? null, + actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop, + }; + }, + [clearTransitionStyles, onOverlayHeightChange, restingControlsRef], + ); + + useLayoutEffect(() => { + const requestId = transitionLayoutRequestRef.current + 1; + transitionLayoutRequestRef.current = requestId; + const stateChanged = previousCollapsedRef.current !== isCollapsed; + // A non-Git context strip enters or leaves flow through ChatView state in + // an earlier layout effect. Let React flush that parent update before the + // FLIP reads its destination geometry, while still running before paint. + queueMicrotask(() => { + if (transitionLayoutRequestRef.current !== requestId) return; + transitionToCurrentGeometry(stateChanged); + }); + return () => { + if (transitionLayoutRequestRef.current === requestId) { + transitionLayoutRequestRef.current += 1; + } + }; + }, [isCollapsed, transitionToCurrentGeometry]); + + useLayoutEffect(() => { + const element = elementRef.current; + if (!element || typeof ResizeObserver === "undefined") return; + + const body = element.querySelector('[data-chat-composer-body="true"]'); + const observer = new ResizeObserver((entries) => { + if (animationRef.current) { + if (body && entries.some((entry) => entry.target === body)) { + transitionToCurrentGeometry(false); + } + return; + } + const elementRect = element.getBoundingClientRect(); + const visibleTransitionElement = (selector: string) => + Array.from(element.querySelectorAll(selector)).find( + (candidate) => candidate.getClientRects().length > 0, + ) ?? null; + const promptRect = visibleTransitionElement( + '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', + )?.getBoundingClientRect(); + const actionTop = visibleTransitionElement( + '[data-chat-composer-transition-actions="true"]', + )?.getBoundingClientRect().top; + previousHeightRef.current = elementRect.height; + previousContentOffsetsRef.current = { + promptFromTop: promptRect === undefined ? null : promptRect.top - elementRect.top, + promptHeight: promptRect?.height ?? null, + actionFromBottom: actionTop === undefined ? null : elementRect.bottom - actionTop, + }; + }); + observer.observe(element); + if (body) observer.observe(body); + return () => observer.disconnect(); + }, [transitionToCurrentGeometry]); + + useEffect(() => { + // Host discovery and width measurement settle through layout updates on + // mount. Treat that bootstrap as initial geometry so an existing thread + // paints at rest instead of visibly collapsing from the expanded height. + hasCompletedInitialLayoutRef.current = true; + return () => { + if (transitionCleanupTimeoutRef.current !== null) { + window.clearTimeout(transitionCleanupTimeoutRef.current); + transitionCleanupTimeoutRef.current = null; + } + animationRef.current?.cancel(); + animationRef.current = null; + animationTargetHeightRef.current = null; + for (const animation of contentAnimationsRef.current) animation.cancel(); + contentAnimationsRef.current = []; + for (const animation of stateChangeAnimationsRef.current) animation.cancel(); + stateChangeAnimationsRef.current = []; + clearTransitionStyles(); + }; + }, [clearTransitionStyles]); + + return elementRef; +} + function composerCommandMenuPositionsEqual( a: ComposerCommandMenuPosition, b: ComposerCommandMenuPosition, @@ -344,15 +794,6 @@ import type { ReviewCommentContext } from "../../reviewCommentContext"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; -const COMPOSER_FLOATING_LAYER_SELECTOR = [ - '[data-composer-drawer-layer="true"]', - '[data-slot="popover-popup"]', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', -].join(","); - const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -381,8 +822,44 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); -function isInsideComposerFloatingLayer(element: Element): boolean { - return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; +function useRestingComposerControlsLayout(host: HTMLDivElement | null) { + const controlsRef = useRef(null); + const hostRef = useRef(host); + hostRef.current = host; + const [layout, setLayout] = useState({ hiddenCount: 0, visible: true }); + + const measure = useCallback(() => { + const currentHost = hostRef.current; + const controls = controlsRef.current; + // The controls only mount while the composer rests, so the expanded + // composer pays no layout reads here despite the every-render effect. + if (currentHost === null || !controls) return; + + const measurement = measureRestingComposerControls(controls); + if (!measurement) return; + const hostWidth = currentHost.clientWidth; + + setLayout((current) => { + const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); + return next.hiddenCount === current.hiddenCount && next.visible === current.visible + ? current + : next; + }); + }, []); + + useLayoutEffect(measure); + useEffect(() => { + if (!host) return; + const observer = new ResizeObserver(measure); + observer.observe(host); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [host, measure]); + + return { controlsRef, hiddenBlockCount: layout.hiddenCount, controlsVisible: layout.visible }; } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { @@ -390,11 +867,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; + size?: "sm" | "xs"; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const runtimeModeConfig = getRuntimeModeConfig(props.provider); const runtimeModeOptions = getRuntimeModeOptions(props.provider); + const size = props.size ?? "sm"; const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -404,16 +883,19 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const interactionModeToggle = props.showInteractionModeToggle ? ( <> - + {props.interactionMode === "plan" ? ( - + ) : ( - + )} {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -437,7 +927,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop return ( <> - + ) : null} | undefined; preferredScriptId: string | null; @@ -126,6 +127,7 @@ export const ChatHeader = memo(function ChatHeader({ activeProjectName, activeProjectCwd, activeProjectFaviconPath, + activeProjectIcon, openInCwd, activeProjectScripts, preferredScriptId, @@ -325,7 +327,9 @@ export const ChatHeader = memo(function ChatHeader({ {activeProjectName} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 2f17f6c03be2..d30fd0e46841 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,7 +1,6 @@ import { ProviderDriverKind, ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; -import { memo, type ReactNode } from "react"; +import { memo, type ReactNode, useState } from "react"; import { EllipsisIcon } from "lucide-react"; -import { Button } from "../ui/button"; import { Menu, MenuPopup, @@ -10,6 +9,8 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; +import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; +import { composerFloatingLayerProps } from "./composerEventScope"; import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { @@ -18,27 +19,45 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + size?: "sm" | "xs"; + /** + * The resting strip keeps this menu mounted out of flow while every block + * fits inline. Its portaled popup would outlive that transition, so an + * open menu closes when its trigger hides. + */ + hidden?: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const runtimeModeConfig = getRuntimeModeConfig(props.provider); const runtimeModeOptions = getRuntimeModeOptions(props.provider); + const size = props.size ?? "sm"; + const [open, setOpen] = useState(false); + const hidden = props.hidden ?? false; + // Base UI does not report a close it did not initiate, so clear the state + // when the trigger hides or the menu would reopen by itself when the + // trigger returns. + const [wasHidden, setWasHidden] = useState(hidden); + if (hidden !== wasHidden) { + setWasHidden(hidden); + if (hidden) setOpen(false); + } return ( - + } > - - + {props.traitsMenuContent ? ( <> {props.traitsMenuContent} diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 3200c2e196e6..90da8940d498 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -58,7 +58,7 @@ function Surface({ "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)", "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint)),linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),rgb(0_0_0/18%)_var(--chat-composer-attachment-overlap),transparent_calc(var(--chat-composer-attachment-overlap)+10px))]", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-attached-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-attached-surface)", className, )} {...props} @@ -89,7 +89,7 @@ function Peek({ neutralOutline, "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-[0_6px_18px_rgb(0_0_0/6%)]", "bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:bg-(--chat-composer-attached-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:bg-(--chat-composer-attached-surface)", "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring", peekBorder[variant], className, diff --git a/apps/web/src/components/chat/ComposerControl.test.tsx b/apps/web/src/components/chat/ComposerControl.test.tsx new file mode 100644 index 000000000000..397578179b9f --- /dev/null +++ b/apps/web/src/components/chat/ComposerControl.test.tsx @@ -0,0 +1,76 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { BotIcon } from "lucide-react"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ComposerControl, + ComposerControlChevron, + ComposerControlIcon, + ComposerControlSeparator, +} from "./ComposerControl"; + +describe("ComposerControl", () => { + it("preserves the expanded composer geometry by default", () => { + const markup = renderToStaticMarkup(Model); + + expect(markup).toContain("h-7"); + expect(markup).toContain("min-h-7"); + expect(markup).toContain("gap-1.5"); + expect(markup).toContain("px-2.5"); + }); + + it("uses the shared xs geometry for resting controls", () => { + const markup = renderToStaticMarkup( + + Model + + , + ); + + expect(markup).toContain("sm:h-6"); + expect(markup).toContain("font-normal"); + expect(markup).toContain("text-muted-foreground/70"); + expect(markup).toContain("[--control-icon-color:currentColor]"); + expect(markup).toContain("svg[data-composer-control-chevron]]:ms-0"); + expect(markup).toContain("svg[data-composer-control-chevron]]:-me-1"); + expect(markup).not.toContain("min-h-7"); + expect(markup).not.toContain("gap-1.5"); + expect(markup).not.toContain("px-2.5"); + }); + + it("keeps the expanded chevron treatment unless resting overrides it", () => { + const expanded = renderToStaticMarkup(); + const resting = renderToStaticMarkup(); + + expect(expanded).toContain("size-3.5"); + expect(expanded).toContain("text-icon-muted"); + expect(expanded).toContain('stroke-width="2.25"'); + expect(resting).toContain("size-3"); + expect(resting).toContain("text-current"); + expect(resting).toContain("opacity-50"); + expect(resting).not.toContain("size-3.5"); + expect(resting).not.toContain("text-icon-muted"); + }); + + it("owns resting icon geometry", () => { + const expanded = renderToStaticMarkup(); + const resting = renderToStaticMarkup(); + + expect(expanded).toContain("size-4"); + expect(resting).toContain("size-3"); + expect(resting).not.toContain("size-4"); + }); + + it("owns separator geometry for both composer sizes", () => { + const expanded = renderToStaticMarkup(); + const resting = renderToStaticMarkup( + , + ); + + expect(expanded).toContain("h-4"); + expect(expanded).not.toContain("h-3.5!"); + expect(resting).toContain("h-3.5!"); + expect(resting).not.toContain("h-4"); + expect(resting).toContain('data-resting-controls-separator="true"'); + }); +}); diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index c0f15a581dad..7b233a68ba8d 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -4,19 +4,37 @@ import { ChevronDownIcon, type LucideIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { SelectTrigger } from "../ui/select"; +import { Separator } from "../ui/separator"; + +export type ComposerControlSize = "sm" | "xs"; + +type ComposerControlProps = Omit, "size"> & { + size?: ComposerControlSize; +}; + +type ComposerSelectControlProps = Omit, "size"> & { + size?: ComposerControlSize; +}; const composerControlClassName = - "h-7 min-h-7 gap-1.5 rounded-[var(--control-radius)] px-2.5 text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; + "rounded-[var(--control-radius)] text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-chevron]]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0"; +const expandedComposerControlClassName = "h-7 min-h-7 gap-1.5 px-2.5"; +const restingComposerControlClassName = + "[--control-icon-color:currentColor] font-normal text-muted-foreground/70 hover:text-foreground/80 [&_svg[data-composer-control-chevron]]:-me-1 [&_svg[data-composer-control-chevron]]:ms-0"; export function ComposerControl({ className, size = "sm", variant = "ghost", ...props -}: ComponentProps) { +}: ComposerControlProps) { return ( - {item.type === "video" && failedVideoSrc === item.src ? ( - -

This video could not be loaded or played.

- -
- ) : item.type === "video" ? ( -
); } -const TIMELINE_LIST_FOOTER =
; +function TimelineListFooter({ composerInset }: { readonly composerInset: number }) { + return ( +
+
+
+
+ ); +} const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, on: { dataChange: true, + // Composer inset changes must not move already-visible messages. New + // rows and row growth still keep live-follow pinned through the other + // triggers below. + footerLayout: false, itemLayout: true, layout: true, }, -} as const; +} as const satisfies MaintainScrollAtEndOptions; // --------------------------------------------------------------------------- // Props (public API) @@ -291,7 +313,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen?: (attachment: ChatFileAttachment) => void; onFileDownload?: (attachment: ChatFileAttachment) => void; - openingVideoAttachmentId: string | null; + openingVideoAttachmentId?: string | null; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; @@ -343,7 +365,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileOpen = NOOP_OPEN_ATTACHMENT, onFileDownload = NOOP_OPEN_ATTACHMENT, - openingVideoAttachmentId, + openingVideoAttachmentId = null, activeThreadEnvironmentId, markdownCwd, resolvedTheme, @@ -378,8 +400,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); - const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment); - useEffect(() => { return () => { if (disclosureSettleFrameRef.current !== null) { @@ -530,15 +550,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onExpandTurn: expandCitedTurn, onManualNavigation, }); - useLayoutEffect(() => { - keepTimelineEndVisibleAfterOverlayGrowth({ - timeline: listRef.current, - previousOverlayHeight: previousContentInsetEndAdjustmentRef.current, - overlayHeight: contentInsetEndAdjustment, - followingEnd: liveFollowEnabled && anchorMessageId === null && !citationPositioning, - }); - previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment; - }, [anchorMessageId, citationPositioning, contentInsetEndAdjustment, listRef, liveFollowEnabled]); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( @@ -558,6 +569,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); + const timelineListFooter = useMemo( + () => , + [anchoredEndSpace, contentInsetEndAdjustment], + ); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -725,6 +740,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ref={listRef} data={rows} + extraData={rows.length} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} @@ -735,7 +751,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})} onLoad={onCitationListLoad} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={contentInsetEndAdjustment} + contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} maintainScrollAtEnd={ citationPositioning || anchoredEndSpace || @@ -747,6 +763,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ maintainVisibleContentPosition={ citationPositioning ? false : maintainVisibleContentPosition } + maintainScrollAtEndThreshold={1} onScroll={handleScroll} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", @@ -765,7 +782,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ TIMELINE_LIST_HEADER ) } - ListFooterComponent={TIMELINE_LIST_FOOTER} + ListFooterComponent={timelineListFooter} /> {row.kind === "work" ? ( @@ -1113,6 +1135,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time anchorKey={row.id} groupedEntries={row.groupedEntries} isExpandedToolGroup={row.isExpandedToolGroup} + displayLabel={row.displayLabel} /> ) : null} {row.kind === "work-live" ? : null} @@ -1122,6 +1145,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} + {row.kind === "assistant-meta" ? : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} @@ -1129,6 +1153,45 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ); }); +function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { + const ctx = use(TimelineRowCtx); + const asset = useMemo( + () => + file.downloadable === false + ? null + : buildAttachmentVideoAsset(ctx.activeThreadEnvironmentId, file), + [ctx.activeThreadEnvironmentId, file.downloadable, file.id, file.mimeType, file.name], + ); + const resource = asset?.resource ?? null; + const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource); + const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null); + + if (asset === null && src === null) { + return ( +
+ {file.name} +
+ ); + } + + return ( + + ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); // The attachment union has an open member, so guards (not literal type @@ -1167,12 +1230,12 @@ function UserTimelineRow({ row }: { row: Extract (
{image.previewUrl ? ( ) : ( @@ -1193,35 +1256,9 @@ function UserTimelineRow({ row }: { row: Extract ))} - {userVideos.map((file) => { - const isOpening = ctx.openingVideoAttachmentId === file.id; - return ( -
- -
- ); - })} + {userVideos.map((file) => ( + + ))}
)} {previewAnnotations.map((annotation, index) => ( @@ -1434,21 +1471,12 @@ function AssistantTimelineRow({ row }: { row: Extract {row.showAssistantMeta ? ( -
- - {!row.message.streaming && ( - - } - > - {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} - - - )} -
+ ) : null}
@@ -1549,11 +1577,81 @@ function MessageFileAttachments(props: { attachments: ReadonlyArray }) { +function AssistantMetaTimelineRow({ + row, +}: { + row: Extract; +}) { + return ( +
+ +
+ ); +} + +function AssistantMessageMeta({ + className, + message, + showCopyButton, + copyStreaming, + alwaysVisible = false, +}: { + className?: string; + message: ChatMessage; + showCopyButton: boolean; + copyStreaming: boolean; + alwaysVisible?: boolean; +}) { + const ctx = use(TimelineRowCtx); + + return ( +
+ + {!message.streaming && ( + + }> + {formatDayAwareTimestamp(message.updatedAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(message.updatedAt, ctx.timestampFormat)} + + + )} +
+ ); +} + +function AssistantCopyButton({ + message, + showCopyButton, + streaming, +}: { + message: ChatMessage; + showCopyButton: boolean; + streaming: boolean; +}) { const assistantCopyState = resolveAssistantMessageCopyState({ - text: row.message.text ?? null, - showCopyButton: row.showAssistantCopyButton, - streaming: row.assistantCopyStreaming, + text: message.text ?? null, + showCopyButton, + streaming, }); if (!assistantCopyState.visible) { @@ -1658,10 +1756,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ anchorKey, groupedEntries, isExpandedToolGroup, + displayLabel, }: { anchorKey: string; groupedEntries: Extract["groupedEntries"]; isExpandedToolGroup: boolean; + displayLabel?: string | undefined; }) { const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( @@ -1690,6 +1790,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ workEntry={workEntry} workspaceRoot={workspaceRoot} isExpandedToolGroupEntry={false} + displayLabel={displayLabel} /> ))}
@@ -1852,10 +1953,12 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) { function LiveActivityRow({ label, iconName, + toolIcon, failed = false, }: { label: string; iconName?: WorkEntryIconName; + toolIcon?: ToolActivityIcon | undefined; failed?: boolean; }) { return ( @@ -1863,11 +1966,18 @@ function LiveActivityRow({ - +
); @@ -1876,17 +1986,20 @@ function LiveActivityRow({ function LiveActivityContent({ label, iconName, + toolIcon, failed = false, announceFailure = false, highlighted = false, }: { label: string; iconName: WorkEntryIconName | undefined; + toolIcon?: ToolActivityIcon | undefined; failed?: boolean; announceFailure?: boolean; highlighted?: boolean; }) { - const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code"; + const isSpecialToolIcon = + iconName === "browser" || iconName === "computer" || iconName === "t3-code"; const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName; return ( @@ -1906,9 +2019,11 @@ function LiveActivityContent({ role={announceFailure ? "img" : undefined} aria-label={announceFailure ? "Tool call failed" : undefined} > - ) : null} @@ -1932,12 +2047,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > {row.active ? ( - + ) : (
@@ -1992,9 +2113,13 @@ function WorkGroupToggleTimelineRow({ onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} > - {row.summary} @@ -2529,6 +2654,7 @@ type WorkEntryIconName = | "browser" | "check" | "circle-alert" + | "computer" | "eye" | "globe" | "hammer" @@ -2541,6 +2667,203 @@ type WorkEntryIconName = | "x" | "zap"; +function BrowserAppIcon({ className }: { className: string }) { + return ( + + + + + + + ); +} + +function ComputerUseAppIcon({ className }: { className: string }) { + const gradientId = `${useId().replaceAll(":", "")}-computer-use-app-gradient`; + return ( + + + + + + + + + + + + + ); +} + +function ToolActivityIconView(props: { + icon: ToolActivityIcon | undefined; + fallbackName: WorkEntryIconName; + className: string; + muted: boolean; +}) { + const { resolvedTheme } = use(TimelineRowCtx); + const fallbackClassName = cn(props.className, props.muted && "opacity-70 light:brightness-[.6]"); + if (!props.icon) { + return ; + } + if (props.icon._tag === "website") { + const src = toolActivityFaviconUrl(props.icon, resolvedTheme, 32); + return src ? ( + + ) : ( + + ); + } + if (props.icon._tag === "themed-logo") { + const src = + resolvedTheme === "dark" + ? (props.icon.logoUrlDark ?? props.icon.logoUrl) + : props.icon.logoUrl; + return ( + + ); + } + return ( + + ); +} + +function NativeAppToolActivityIcon(props: { + app: Extract["app"]; + fallbackName: WorkEntryIconName; + className: string; + muted: boolean; +}) { + const { activeThreadEnvironmentId } = use(TimelineRowCtx); + const asset = useAssetUrlState(activeThreadEnvironmentId, { + _tag: "native-app-icon", + app: props.app, + }); + if (asset._tag !== "Success") { + return ( + + ); + } + const cacheKey = getProjectFaviconCacheKey( + activeThreadEnvironmentId, + JSON.stringify(props.app), + asset.url, + ); + return ( + + ); +} + +const loadedToolActivityIconSrcs = new Map(); + +function ToolActivityImageIcon(props: { + cacheKey: string; + src: string; + fallbackName: WorkEntryIconName; + className: string; + muted: boolean; +}) { + const [displayedSrc, setDisplayedSrc] = useState( + () => loadedToolActivityIconSrcs.get(props.cacheKey) ?? null, + ); + const isLoading = displayedSrc !== props.src; + const handleLoadError = (failedSrc: string) => { + if (loadedToolActivityIconSrcs.get(props.cacheKey) === failedSrc) { + loadedToolActivityIconSrcs.delete(props.cacheKey); + } + setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); + }; + return ( + <> + {displayedSrc === null ? ( + + ) : null} + {displayedSrc ? ( + + handleLoadError(displayedSrc)} + /> + + ) : null} + {isLoading ? ( + { + loadedToolActivityIconSrcs.set(props.cacheKey, props.src); + setDisplayedSrc(props.src); + }} + onError={() => handleLoadError(props.src)} + /> + ) : null} + + ); +} + function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { case "bot": @@ -2548,7 +2871,9 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className case "brain": return ; case "browser": - return ; + return ; + case "computer": + return ; case "t3-code": return ; case "check": @@ -2654,6 +2979,7 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { ) { return "message-circle"; } + if (workEntry.toolSurface) return workEntry.toolSurface; const toolPresentation = resolveWorkEntryToolPresentation(workEntry); if (toolPresentation) return toolPresentation.icon; const action = toolGroupAction(workEntry); @@ -2776,8 +3102,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; + displayLabel?: string | undefined; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; @@ -2787,6 +3114,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry={workEntry} workspaceRoot={workspaceRoot} isExpandedToolGroupEntry={isExpandedToolGroupEntry} + displayLabel={displayLabel} /> ); }); @@ -2795,8 +3123,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; + displayLabel?: string | undefined; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); const groupView = use(WorkGroupViewCtx); const [expanded, setExpanded] = useState( @@ -2815,11 +3144,12 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); const toolPresentation = resolveWorkEntryToolPresentation(workEntry); + const hasSpecialToolIcon = toolPresentation !== null || workEntry.toolSurface !== undefined; const entryIconName = - showWarningIndicator || (showFailedIndicator && !toolPresentation) + showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon) ? "circle-alert" : workEntryIconName(workEntry); - const previewText = workEntryDisplayLabel(workEntry, workspaceRoot); + const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); const displayText = !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; const canExpand = @@ -2896,9 +3226,15 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { role={showFailedIndicator ? "img" : undefined} aria-label={showFailedIndicator ? "Tool call failed" : undefined} > -
@@ -2907,7 +3243,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { {displayText}

- {showFailedIndicator && toolPresentation ? ( + {showFailedIndicator && hasSpecialToolIcon ? ( ) : null}
+ {viewedImage && threadRef ? ( +
+ +
+ ) : null} {expanded && canExpand && expandedBody ? (
- {viewedImage && threadRef ? ( -
- -
- ) : null}
{expandedBody}
) : null} diff --git a/apps/web/src/components/chat/PanelLayoutControls.test.tsx b/apps/web/src/components/chat/PanelLayoutControls.test.tsx deleted file mode 100644 index 51ae1a73ad0f..000000000000 --- a/apps/web/src/components/chat/PanelLayoutControls.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { PanelLayoutControls } from "./PanelLayoutControls"; - -describe("PanelLayoutControls", () => { - it("keeps unavailable panel tooltip triggers interactive", () => { - const markup = renderToStaticMarkup( - {}} - onToggleRightPanel={() => {}} - />, - ); - - expect(markup.match(/data-slot="tooltip-trigger"/g)).toHaveLength(2); - expect(markup.match(/data-slot="tooltip-trigger"[^>]*>]*disabled=""/g)).toHaveLength( - 2, - ); - }); -}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx index 5d5bf94ce631..769ffae3d091 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -99,4 +99,24 @@ describe("ProviderModelPicker", () => { expect(markup).toContain("Fallback model"); expect(markup).not.toContain(">missing-model<"); }); + + it("keeps instance initials visible in the resting trigger", () => { + const activeEntry = providerEntry("codex_personal", "codex"); + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain(">CP
"); + expect(markup).toContain("size-4"); + expect(markup).toContain("h-3"); + expect(markup).toContain("text-[7px]"); + }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index db55edcf8835..9e3f167efbc7 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -18,7 +18,12 @@ import { getTriggerDisplayModelName, } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; -import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; +import { + ComposerControl, + ComposerControlChevron, + type ComposerControlSize, +} from "./ComposerControl"; +import { composerFloatingLayerProps } from "./composerEventScope"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -34,7 +39,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { keybindings?: ResolvedKeybindingsConfig; modelOptionsByInstance: ReadonlyMap>; activeProviderIconClassName?: string; + instanceIndicatorBackground?: string; + size?: ComposerControlSize; compact?: boolean; + isComposerOwned?: boolean; disabled?: boolean; terminalOpen?: boolean; open?: boolean; @@ -47,6 +55,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }) { const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; + const size = props.size ?? "sm"; // Resolve the active instance entry by exact routing key. The composer // resolves fallbacks before rendering this component; if the selected @@ -148,6 +157,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { } > - + {activeEntry ? ( ) : null} - }> + + } + > {triggerTitle} {triggerLabel} @@ -187,13 +206,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : null} ; @@ -274,6 +280,7 @@ export interface TraitsMenuContentProps { planModeEnabled: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + isComposerOwned?: boolean; } export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ @@ -540,8 +547,13 @@ export const TraitsPicker = memo(function TraitsPicker({ planModeEnabled, triggerVariant, triggerClassName, + isComposerOwned, + size = "sm", ...persistence -}: TraitsMenuContentProps & TraitsPersistence) { +}: TraitsMenuContentProps & + TraitsPersistence & { + size?: ComposerControlSize; + }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ @@ -577,6 +589,7 @@ export const TraitsPicker = memo(function TraitsPicker({ <> {isCodexStyle ? ( - + {fastModeIcon} {triggerLabel} - + ) : ( <> {fastModeIcon} {triggerLabel} - + )} - + candidate === this.matchingSelector) + ? this + : null; + } +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("composer event scopes", () => { + it("recognizes events from the portaled resting controls", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement('[data-chat-composer-resting-controls="true"]'); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); + }); + + it("keeps resting image previews focused without expanding their subtree", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement('[data-chat-composer-resting-images="true"]'); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); + }); + + it("includes composer-owned floating layers in the resting control scope", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement('[data-chat-composer-floating-layer="true"]'); + expect(isInsideComposerFloatingLayer(target as unknown as EventTarget)).toBe(true); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); + }); + + it("leaves unrelated floating layers outside the composer scope", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement('[data-slot="popover-popup"]'); + expect(isInsideComposerFloatingLayer(target as unknown as EventTarget)).toBe(false); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false); + }); + + it("leaves ordinary composer targets outside the portaled control scope", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement(null); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false); + expect(isInsideRestingComposerControlScope(null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts new file mode 100644 index 000000000000..2275fdd21a2e --- /dev/null +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -0,0 +1,21 @@ +const COMPOSER_FLOATING_LAYER_SELECTOR = [ + '[data-composer-drawer-layer="true"]', + '[data-chat-composer-floating-layer="true"]', +].join(","); + +export const composerFloatingLayerProps = { + "data-chat-composer-floating-layer": "true", +} as const; + +export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean { + return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; +} + +export function isInsideRestingComposerControlScope(target: EventTarget | null): boolean { + return ( + target instanceof Element && + (target.closest('[data-chat-composer-resting-controls="true"]') !== null || + target.closest('[data-chat-composer-resting-images="true"]') !== null || + isInsideComposerFloatingLayer(target)) + ); +} diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 5da27505391d..9a3675885083 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -12,10 +12,13 @@ import { isClaudeUltrathinkPrompt, normalizeModelSlug, } from "@t3tools/shared/model"; +import type { VariantProps } from "class-variance-authority"; import type { ReactNode } from "react"; +import type { buttonVariants } from "../ui/button"; import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; +import type { ComposerControlSize } from "./ComposerControl"; import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { @@ -49,6 +52,10 @@ type TraitsRenderInput = { prompt: string; onPromptChange: (prompt: string) => void; planModeEnabled: boolean; + size?: ComposerControlSize; + triggerVariant?: VariantProps["variant"]; + triggerClassName?: string; + isComposerOwned?: boolean; }; export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { @@ -123,6 +130,10 @@ function renderTraitsControl( prompt, onPromptChange, planModeEnabled, + size, + triggerVariant, + triggerClassName, + isComposerOwned, } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; if ( @@ -150,6 +161,10 @@ function renderTraitsControl( prompt={prompt} onPromptChange={onPromptChange} planModeEnabled={planModeEnabled} + {...(size !== undefined ? { size } : {})} + {...(triggerVariant !== undefined ? { triggerVariant } : {})} + {...(triggerClassName !== undefined ? { triggerClassName } : {})} + {...(isComposerOwned ? { isComposerOwned } : {})} /> ); } diff --git a/apps/web/src/components/chat/composerScrollGesture.test.ts b/apps/web/src/components/chat/composerScrollGesture.test.ts new file mode 100644 index 000000000000..ae87f6ca8cc7 --- /dev/null +++ b/apps/web/src/components/chat/composerScrollGesture.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + createComposerScrollGestureState, + recordComposerScrollGestureEvent, + resetComposerScrollGesture, + suppressActiveComposerScrollGesture, +} from "./composerScrollGesture"; + +const RESET_MS = 120; +const THRESHOLD_PX = 24; + +function record( + state: ReturnType, + now: number, + options: Partial<{ + deltaPx: number; + collapseEligible: boolean; + canScrollInGestureDirection: boolean; + scrollsTowardLogicalEnd: boolean; + }> = {}, +) { + return recordComposerScrollGestureEvent(state, { + now, + deltaPx: options.deltaPx ?? 30, + collapseThresholdPx: THRESHOLD_PX, + collapseEligible: options.collapseEligible ?? true, + canScrollInGestureDirection: options.canScrollInGestureDirection ?? true, + scrollsTowardLogicalEnd: options.scrollsTowardLogicalEnd ?? false, + }); +} + +describe("composer scroll gesture", () => { + it("lets an editor change win over the rest of the active gesture", () => { + const state = createComposerScrollGestureState(); + + expect(record(state, 0)).toBe(true); + suppressActiveComposerScrollGesture(state, 20, RESET_MS); + + expect(record(state, 40)).toBe(false); + expect(record(state, 80)).toBe(false); + expect(record(state, 160)).toBe(false); + }); + + it("preserves suppression while collapse is temporarily ineligible", () => { + const state = createComposerScrollGestureState(); + + record(state, 0); + suppressActiveComposerScrollGesture(state, 20, RESET_MS); + + expect(record(state, 40, { collapseEligible: false })).toBe(false); + expect(record(state, 80, { collapseEligible: true })).toBe(false); + }); + + it("keeps a boundary momentum tail in the same suppressed gesture", () => { + const state = createComposerScrollGestureState(); + + record(state, 0); + suppressActiveComposerScrollGesture(state, 20, RESET_MS); + + expect(record(state, 80, { canScrollInGestureDirection: false })).toBe(false); + expect(state.lastEventAt).toBe(80); + expect(record(state, 160)).toBe(false); + }); + + it("does not carry gesture state into a new thread after reset", () => { + const state = createComposerScrollGestureState(); + + record(state, 0); + suppressActiveComposerScrollGesture(state, 20, RESET_MS); + record(state, 80); + resetComposerScrollGesture(state); + + expect(record(state, 240)).toBe(true); + }); + + it("accumulates small deltas only while collapse remains eligible and scrollable", () => { + const state = createComposerScrollGestureState(); + + expect(record(state, 0, { deltaPx: 10 })).toBe(false); + expect(record(state, 20, { deltaPx: 10 })).toBe(false); + expect(record(state, 40, { deltaPx: 4 })).toBe(true); + expect(record(state, 60, { deltaPx: 10, collapseEligible: false })).toBe(false); + expect(record(state, 80, { deltaPx: 14 })).toBe(false); + }); + + it("does not collapse while scrolling down through composer footer space", () => { + const state = createComposerScrollGestureState(); + + expect(record(state, 0, { deltaPx: 20 })).toBe(false); + expect(record(state, 20, { scrollsTowardLogicalEnd: true })).toBe(false); + expect(record(state, 40, { deltaPx: 10 })).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/composerScrollGesture.ts b/apps/web/src/components/chat/composerScrollGesture.ts new file mode 100644 index 000000000000..3c76985dea22 --- /dev/null +++ b/apps/web/src/components/chat/composerScrollGesture.ts @@ -0,0 +1,60 @@ +export type ComposerScrollGestureState = { + accumulatedDeltaPx: number; + collapseSuppressed: boolean; + lastEventAt: number; +}; + +export function createComposerScrollGestureState(): ComposerScrollGestureState { + return { + accumulatedDeltaPx: 0, + collapseSuppressed: false, + lastEventAt: Number.NEGATIVE_INFINITY, + }; +} + +export function resetComposerScrollGesture(state: ComposerScrollGestureState): void { + state.accumulatedDeltaPx = 0; + state.collapseSuppressed = false; + state.lastEventAt = Number.NEGATIVE_INFINITY; +} + +export function suppressActiveComposerScrollGesture( + state: ComposerScrollGestureState, + now: number, + gestureResetMs: number, +): void { + if (now - state.lastEventAt <= gestureResetMs) { + state.collapseSuppressed = true; + } +} + +export function recordComposerScrollGestureEvent( + state: ComposerScrollGestureState, + input: { + now: number; + deltaPx: number; + collapseThresholdPx: number; + collapseEligible: boolean; + canScrollInGestureDirection: boolean; + scrollsTowardLogicalEnd: boolean; + }, +): boolean { + state.lastEventAt = input.now; + if ( + state.collapseSuppressed || + !input.collapseEligible || + !input.canScrollInGestureDirection || + input.scrollsTowardLogicalEnd + ) { + state.accumulatedDeltaPx = 0; + return false; + } + + state.accumulatedDeltaPx += input.deltaPx; + if (state.accumulatedDeltaPx < input.collapseThresholdPx) { + return false; + } + + state.accumulatedDeltaPx = 0; + return true; +} diff --git a/apps/web/src/components/chat/pageScrollController.test.ts b/apps/web/src/components/chat/pageScrollController.test.ts new file mode 100644 index 000000000000..9fe399f50d3e --- /dev/null +++ b/apps/web/src/components/chat/pageScrollController.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test } from "vite-plus/test"; + +import { + createPageScrollController, + getTimelinePageScrollKey, + getPageScrollDistancePx, + getPageScrollMultiplier, + getPageScrollVelocityPxPerMs, + PAGE_SCROLL_ACCELERATION_MS, + PAGE_SCROLL_ANIMATION_MS, + PAGE_SCROLL_MAX_MULTIPLIER, +} from "./pageScrollController"; + +class TestClock { + private currentTime = 0; + private nextHandle = 1; + private animationFrames = new Map(); + private timeouts = new Map void }>(); + + readonly env = { + now: () => this.currentTime, + requestAnimationFrame: (callback: FrameRequestCallback) => { + const handle = this.nextHandle; + this.nextHandle += 1; + this.animationFrames.set(handle, callback); + return handle; + }, + cancelAnimationFrame: (handle: number) => { + this.animationFrames.delete(handle); + }, + setTimeout: (callback: () => void, delay: number) => { + const handle = this.nextHandle; + this.nextHandle += 1; + this.timeouts.set(handle, { at: this.currentTime + delay, callback }); + return handle; + }, + clearTimeout: (handle: number) => { + this.timeouts.delete(handle); + }, + }; + + advanceBy(ms: number, frameMs = 16) { + const target = this.currentTime + ms; + + while (this.currentTime < target) { + this.currentTime = Math.min(target, this.currentTime + frameMs); + this.flushTimeouts(); + this.flushAnimationFrames(); + } + } + + private flushTimeouts() { + let hasDueTimeouts = true; + while (hasDueTimeouts) { + hasDueTimeouts = false; + + for (const [handle, timeout] of this.timeouts) { + if (timeout.at > this.currentTime) { + continue; + } + + this.timeouts.delete(handle); + timeout.callback(); + hasDueTimeouts = true; + } + } + } + + private flushAnimationFrames() { + if (this.animationFrames.size === 0) { + return; + } + + const frames = [...this.animationFrames.values()]; + this.animationFrames.clear(); + + for (const callback of frames) { + callback(this.currentTime); + } + } +} + +describe("page scroll helpers", () => { + const composerPageScrollEvent = ( + overrides: Partial[0]> = {}, + ) => ({ + altKey: false, + clientHeight: 200, + ctrlKey: false, + defaultPrevented: false, + isComposing: false, + key: "PageDown", + keyCode: 34, + metaKey: false, + scrollHeight: 200, + scrollTop: 0, + shiftKey: false, + ...overrides, + }); + + test("leaves page keys to IME composition", () => { + expect(getTimelinePageScrollKey(composerPageScrollEvent({ isComposing: true }))).toBeNull(); + expect(getTimelinePageScrollKey(composerPageScrollEvent({ keyCode: 229 }))).toBeNull(); + }); + + test("leaves page keys to an overflowing composer until it reaches the boundary", () => { + expect( + getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 0 })), + ).toBeNull(); + expect( + getTimelinePageScrollKey( + composerPageScrollEvent({ + key: "PageUp", + keyCode: 33, + scrollHeight: 600, + scrollTop: 400, + }), + ), + ).toBeNull(); + + expect( + getTimelinePageScrollKey( + composerPageScrollEvent({ + key: "PageUp", + keyCode: 33, + scrollHeight: 600, + scrollTop: 0, + }), + ), + ).toBe("PageUp"); + expect( + getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 400 })), + ).toBe("PageDown"); + }); + + test("hands off page keys within a fractional pixel of the composer boundary", () => { + expect( + getTimelinePageScrollKey( + composerPageScrollEvent({ + key: "PageUp", + keyCode: 33, + scrollHeight: 600, + scrollTop: 0.5, + }), + ), + ).toBe("PageUp"); + expect( + getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 399.5 })), + ).toBe("PageDown"); + }); + + test("ramps multiplier over time and caps at the max velocity", () => { + expect(getPageScrollMultiplier(0)).toBe(1); + expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS / 2)).toBeCloseTo(1.5); + expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS * 5)).toBe( + PAGE_SCROLL_MAX_MULTIPLIER, + ); + }); + + test("derives the hold velocity from page size and acceleration", () => { + expect( + getPageScrollVelocityPxPerMs({ + holdElapsedMs: 0, + pageScrollDistancePx: 600, + }), + ).toBeCloseTo(4); + expect( + getPageScrollVelocityPxPerMs({ + holdElapsedMs: PAGE_SCROLL_ACCELERATION_MS * 5, + pageScrollDistancePx: 600, + }), + ).toBeCloseTo(8); + }); +}); + +describe("createPageScrollController", () => { + test("keeps a single page scroll when the key is tapped", () => { + const clock = new TestClock(); + const container = { + clientHeight: 600, + scrollHeight: 1_800, + scrollTop: 0, + getBoundingClientRect: () => ({ height: 600 }), + }; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + env: clock.env, + }); + + controller.handleKeyDown("PageDown"); + controller.handleKeyUp("PageDown"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS); + + expect(container.scrollTop).toBeCloseTo( + getPageScrollDistancePx({ + containerHeightPx: 600, + scrollPaddingBottomPx: 24, + }), + 5, + ); + }); + + test("continues scrolling on hold without repeated keydown events and stops on keyup", () => { + const clock = new TestClock(); + const container = { + clientHeight: 600, + scrollHeight: 4_000, + scrollTop: 0, + getBoundingClientRect: () => ({ height: 600 }), + }; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + env: clock.env, + }); + controller.handleKeyDown("PageDown"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50); + + const afterHoldStarts = container.scrollTop; + clock.advanceBy(200); + + expect(container.scrollTop).toBeGreaterThan(afterHoldStarts); + + const stoppedAt = container.scrollTop; + controller.handleKeyUp("PageDown"); + clock.advanceBy(250); + + expect(container.scrollTop).toBe(stoppedAt); + }); + + test("notifies once when a page scroll starts", () => { + const clock = new TestClock(); + const started: string[] = []; + const controller = createPageScrollController({ + getContainer: () => ({ + clientHeight: 600, + scrollHeight: 1_800, + scrollTop: 600, + getBoundingClientRect: () => ({ height: 600 }), + }), + getScrollPaddingBottomPx: () => 24, + onScrollStart: (key) => started.push(key), + env: clock.env, + }); + + controller.handleKeyDown("PageUp"); + controller.handleKeyDown("PageUp"); + + expect(started).toEqual(["PageUp"]); + }); + + test("does not start a page scroll at the timeline boundary", () => { + const clock = new TestClock(); + const started: string[] = []; + const container = { + clientHeight: 600, + scrollHeight: 1_800, + scrollTop: 0.5, + getBoundingClientRect: () => ({ height: 600 }), + }; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + onScrollStart: (key) => started.push(key), + env: clock.env, + }); + + controller.handleKeyDown("PageUp"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2); + + container.scrollTop = container.scrollHeight - container.clientHeight - 0.5; + controller.handleKeyDown("PageDown"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2); + + expect(started).toEqual([]); + expect(container.scrollTop).toBe(1_199.5); + }); +}); diff --git a/apps/web/src/components/chat/pageScrollController.ts b/apps/web/src/components/chat/pageScrollController.ts new file mode 100644 index 000000000000..4c3999b1efc1 --- /dev/null +++ b/apps/web/src/components/chat/pageScrollController.ts @@ -0,0 +1,307 @@ +export const PAGE_SCROLL_ANIMATION_MS = 150; +export const PAGE_SCROLL_ACCELERATION_MS = 400; +export const PAGE_SCROLL_MAX_MULTIPLIER = 2; + +const PAGE_SCROLL_ALIGNMENT_OFFSET_PX = 36; +const PAGE_SCROLL_BOUNDARY_EPSILON_PX = 1; +const PAGE_SCROLL_HOLD_DELAY_MS = PAGE_SCROLL_ANIMATION_MS; + +export type PageScrollKey = "PageUp" | "PageDown"; + +type PageScrollMetrics = { + clientHeight: number; + scrollHeight: number; + scrollTop: number; +}; + +function canScrollInDirection( + { clientHeight, scrollHeight, scrollTop }: PageScrollMetrics, + key: PageScrollKey, +): boolean { + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const clampedScrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop)); + return key === "PageUp" + ? clampedScrollTop > PAGE_SCROLL_BOUNDARY_EPSILON_PX + : clampedScrollTop < maxScrollTop - PAGE_SCROLL_BOUNDARY_EPSILON_PX; +} + +export function getTimelinePageScrollKey({ + altKey, + clientHeight, + ctrlKey, + defaultPrevented, + isComposing, + key, + keyCode, + metaKey, + scrollHeight, + scrollTop, + shiftKey, +}: { + altKey: boolean; + clientHeight: number; + ctrlKey: boolean; + defaultPrevented: boolean; + isComposing: boolean; + key: string; + keyCode: number; + metaKey: boolean; + scrollHeight: number; + scrollTop: number; + shiftKey: boolean; +}): PageScrollKey | null { + if (key !== "PageUp" && key !== "PageDown") { + return null; + } + if ( + defaultPrevented || + isComposing || + keyCode === 229 || + altKey || + ctrlKey || + metaKey || + shiftKey + ) { + return null; + } + + const editorCanScroll = canScrollInDirection({ clientHeight, scrollHeight, scrollTop }, key); + return editorCanScroll ? null : key; +} + +type PageScrollContainer = PageScrollMetrics & { + getBoundingClientRect: () => { + height: number; + }; +}; + +type PageScrollEnv = { + now: () => number; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + cancelAnimationFrame: (handle: number) => void; + setTimeout: (callback: () => void, delay: number) => number; + clearTimeout: (handle: number) => void; +}; + +function getDefaultEnv(): PageScrollEnv { + return { + now: () => performance.now(), + requestAnimationFrame: (callback) => window.requestAnimationFrame(callback), + cancelAnimationFrame: (handle) => window.cancelAnimationFrame(handle), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + clearTimeout: (handle) => window.clearTimeout(handle), + }; +} + +export function getPageScrollMultiplier(holdElapsedMs: number): number { + const progress = Math.max(0, holdElapsedMs) / PAGE_SCROLL_ACCELERATION_MS; + return 1 + Math.min(progress, 1) * (PAGE_SCROLL_MAX_MULTIPLIER - 1); +} + +export function getPageScrollVelocityPxPerMs({ + holdElapsedMs, + pageScrollDistancePx, +}: { + holdElapsedMs: number; + pageScrollDistancePx: number; +}): number { + return (pageScrollDistancePx * getPageScrollMultiplier(holdElapsedMs)) / PAGE_SCROLL_ANIMATION_MS; +} + +export function getPageScrollDistancePx({ + containerHeightPx, + scrollPaddingBottomPx, +}: { + containerHeightPx: number; + scrollPaddingBottomPx: number; +}): number { + return Math.max(0, containerHeightPx - PAGE_SCROLL_ALIGNMENT_OFFSET_PX - scrollPaddingBottomPx); +} + +function getDirection(key: PageScrollKey): -1 | 1 { + return key === "PageUp" ? -1 : 1; +} + +function easeInOut(progress: number): number { + const ax = 3 * 0.42 - 3 * 0.58 + 1; + const bx = 3 * (0.58 - 2 * 0.42); + const cx = 3 * 0.42; + const ay = -2; + const by = 3; + const x = (value: number) => ((ax * value + bx) * value + cx) * value; + const y = (value: number) => (ay * value + by) * value * value; + + let value = progress; + for (let index = 0; index < 5; index += 1) { + const delta = x(value) - progress; + const derivative = (3 * ax * value + 2 * bx) * value + cx; + if (Math.abs(delta) < 1e-4 || derivative === 0) { + break; + } + value -= delta / derivative; + value = Math.min(1, Math.max(0, value)); + } + + return y(value); +} + +export function createPageScrollController({ + getContainer, + getScrollPaddingBottomPx, + onScrollStart, + env = getDefaultEnv(), +}: { + getContainer: () => PageScrollContainer | null; + getScrollPaddingBottomPx: () => number; + onScrollStart?: (key: PageScrollKey) => void; + env?: PageScrollEnv; +}) { + const state = { + activeKey: null as PageScrollKey | null, + discreteAnimationFrame: 0, + holdDelayTimeout: 0, + holdAnimationFrame: 0, + holdStartTime: 0, + lastFrameTime: 0, + holdActive: false, + }; + + const readPageScrollDistance = (container: PageScrollContainer) => + getPageScrollDistancePx({ + containerHeightPx: container.getBoundingClientRect().height, + scrollPaddingBottomPx: getScrollPaddingBottomPx(), + }); + + const cancelDiscreteAnimation = () => { + if (state.discreteAnimationFrame === 0) { + return; + } + + env.cancelAnimationFrame(state.discreteAnimationFrame); + state.discreteAnimationFrame = 0; + }; + + const stop = ({ + cancelDiscreteAnimation: shouldCancelDiscreteAnimation, + }: { + cancelDiscreteAnimation: boolean; + }) => { + if (state.holdDelayTimeout !== 0) { + env.clearTimeout(state.holdDelayTimeout); + state.holdDelayTimeout = 0; + } + + if (state.holdAnimationFrame !== 0) { + env.cancelAnimationFrame(state.holdAnimationFrame); + state.holdAnimationFrame = 0; + } + + if (shouldCancelDiscreteAnimation) { + cancelDiscreteAnimation(); + } + + state.activeKey = null; + state.holdStartTime = 0; + state.lastFrameTime = 0; + state.holdActive = false; + }; + + const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => { + cancelDiscreteAnimation(); + + const startScrollTop = container.scrollTop; + const startTime = env.now(); + + const step = (now: number) => { + const progress = Math.min(1, (now - startTime) / PAGE_SCROLL_ANIMATION_MS); + container.scrollTop = startScrollTop + deltaY * easeInOut(progress); + + if (progress < 1) { + state.discreteAnimationFrame = env.requestAnimationFrame(step); + return; + } + + state.discreteAnimationFrame = 0; + }; + + state.discreteAnimationFrame = env.requestAnimationFrame(step); + }; + + const startHoldScroll = (key: PageScrollKey, container: PageScrollContainer) => { + cancelDiscreteAnimation(); + state.holdActive = true; + state.holdStartTime = env.now(); + state.lastFrameTime = state.holdStartTime; + + const step = (now: number) => { + if (state.activeKey !== key) { + state.holdAnimationFrame = 0; + return; + } + + const deltaMs = now - state.lastFrameTime; + state.lastFrameTime = now; + + const velocityPxPerMs = getPageScrollVelocityPxPerMs({ + holdElapsedMs: now - state.holdStartTime, + pageScrollDistancePx: readPageScrollDistance(container), + }); + const previousScrollTop = container.scrollTop; + container.scrollTop = previousScrollTop + velocityPxPerMs * deltaMs * getDirection(key); + + if (deltaMs > 0 && container.scrollTop === previousScrollTop) { + stop({ cancelDiscreteAnimation: true }); + return; + } + + state.holdAnimationFrame = env.requestAnimationFrame(step); + }; + + state.holdAnimationFrame = env.requestAnimationFrame(step); + }; + + return { + handleKeyDown(key: PageScrollKey) { + const container = getContainer(); + if (!container) { + return; + } + + if (!canScrollInDirection(container, key)) { + return; + } + + if (state.activeKey === key) { + return; + } + + stop({ cancelDiscreteAnimation: true }); + state.activeKey = key; + onScrollStart?.(key); + + smoothScrollBy(container, readPageScrollDistance(container) * getDirection(key)); + + state.holdDelayTimeout = env.setTimeout(() => { + state.holdDelayTimeout = 0; + if (state.activeKey !== key) { + return; + } + + startHoldScroll(key, container); + }, PAGE_SCROLL_HOLD_DELAY_MS); + }, + handleKeyUp(key: string) { + if (state.activeKey !== key) { + return; + } + + stop({ cancelDiscreteAnimation: state.holdActive }); + }, + releaseActiveKey() { + stop({ cancelDiscreteAnimation: state.holdActive }); + }, + dispose() { + stop({ cancelDiscreteAnimation: true }); + }, + }; +} diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts new file mode 100644 index 000000000000..3bd4242d457c --- /dev/null +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts @@ -0,0 +1,77 @@ +import type { RestingComposerControlsMeasurement } from "../composerFooterLayout"; + +function elementOuterWidth(element: HTMLElement): number { + const width = element.getBoundingClientRect().width; + if (width === 0) return 0; + const style = getComputedStyle(element); + return ( + width + + (Number.parseFloat(style.marginInlineStart) || 0) + + (Number.parseFloat(style.marginInlineEnd) || 0) + ); +} + +function elementInlineMarginWidth(element: HTMLElement): number { + const style = getComputedStyle(element); + return ( + (Number.parseFloat(style.marginInlineStart) || 0) + + (Number.parseFloat(style.marginInlineEnd) || 0) + ); +} + +function providerModelPickerNaturalWidth(picker: HTMLElement): number { + const renderedWidth = picker.getBoundingClientRect().width; + if (renderedWidth === 0) return 0; + const style = getComputedStyle(picker); + const label = picker.querySelector('[data-chat-provider-model-picker-label="true"]'); + const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; + const maxWidth = Number.parseFloat(style.maxWidth); + const naturalWidth = Math.min( + renderedWidth + hiddenLabelWidth, + Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY, + ); + return naturalWidth + elementInlineMarginWidth(picker); +} + +function providerModelPickerMinimumWidth(picker: HTMLElement): number { + const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0; + return minWidth + elementInlineMarginWidth(picker); +} + +/** + * Read the natural widths of the resting composer controls from the DOM. + * + * Both the composer (deciding which blocks move into overflow) and the + * context strip (deciding whether its labels may expand) read the same + * numbers, so neither decision depends on what the other one hid last render. + * + * Hidden blocks and the unused overflow trigger stay mounted out of flow at + * full size. The picker is the one flexible item: its intended width is + * recovered from the truncated label. + */ +export function measureRestingComposerControls( + controls: HTMLElement, +): RestingComposerControlsMeasurement | null { + const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0; + const picker = controls.querySelector("[data-chat-provider-model-picker]"); + const leadingControl = + picker ?? controls.querySelector('[data-chat-provider-unavailable="true"]'); + if (!leadingControl) return null; + // Separators are display:none on phone widths; a hidden one takes no gap. + const separator = controls.querySelector("[data-resting-controls-separator]"); + const separatorWidth = separator ? elementOuterWidth(separator) : 0; + const overflow = controls.querySelector("[data-resting-controls-overflow]"); + const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; + const blocks = Array.from(controls.querySelectorAll("[data-resting-block]")); + return { + gap, + naturalFixedWidth: + (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) + + separatorAndGapWidth, + minimumFixedWidth: + (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + + separatorAndGapWidth, + blockWidths: blocks.map(elementOuterWidth), + overflowWidth: overflow ? elementOuterWidth(overflow) : 0, + }; +} diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 50453c55cb17..1bf82c47a614 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,9 +1,5 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { - getAnchoredTurnMetrics, - getRowBottom, - keepTimelineEndVisibleAfterOverlayGrowth, -} from "./timelineScrollAnchoring"; +import { describe, expect, it } from "vite-plus/test"; +import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; function buildState({ positions, @@ -26,33 +22,6 @@ function buildState({ } describe("timeline scroll anchoring", () => { - it("keeps the live edge visible when the composer overlay grows", () => { - const scrollToEnd = vi.fn(); - - keepTimelineEndVisibleAfterOverlayGrowth({ - timeline: { scrollToEnd }, - previousOverlayHeight: 120, - overlayHeight: 180, - followingEnd: true, - }); - - expect(scrollToEnd).toHaveBeenCalledOnce(); - expect(scrollToEnd).toHaveBeenCalledWith({ animated: false }); - }); - - it("leaves the scroll position alone while the user reads history", () => { - const scrollToEnd = vi.fn(); - - keepTimelineEndVisibleAfterOverlayGrowth({ - timeline: { scrollToEnd }, - previousOverlayHeight: 120, - overlayHeight: 180, - followingEnd: false, - }); - - expect(scrollToEnd).not.toHaveBeenCalled(); - }); - it("measures row bottoms from LegendList row position and size", () => { const state = buildState({ positions: [0, 120], diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 505efef29d21..4b011e235900 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -22,22 +22,6 @@ export interface AnchoredTurnMetrics { readonly scrollDeltaToRevealEnd: number; } -export function keepTimelineEndVisibleAfterOverlayGrowth({ - timeline, - previousOverlayHeight, - overlayHeight, - followingEnd, -}: { - readonly timeline: { scrollToEnd: (options: { animated: boolean }) => unknown } | null; - readonly previousOverlayHeight: number; - readonly overlayHeight: number; - readonly followingEnd: boolean; -}): void { - if (timeline && followingEnd && overlayHeight > previousOverlayHeight) { - void timeline.scrollToEnd({ animated: false }); - } -} - export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null { const top = state.positionAtIndex(index); const height = state.sizeAtIndex(index); diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 92e054df52dc..926816508ec5 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -1,12 +1,35 @@ import { describe, expect, it } from "vite-plus/test"; +import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, + getRestingComposerImagePreviewCounts, + resolveRestingComposerControlsLayout, + resolveRestingComposerControlsNaturalWidth, + shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, + shouldUseRestingComposerLayout, } from "./composerFooterLayout"; +describe("getRestingComposerImagePreviewCounts", () => { + it("shows at most three thumbnails and counts the remainder", () => { + expect(getRestingComposerImagePreviewCounts(0)).toEqual({ + visibleCount: 0, + overflowCount: 0, + }); + expect(getRestingComposerImagePreviewCounts(3)).toEqual({ + visibleCount: 3, + overflowCount: 0, + }); + expect(getRestingComposerImagePreviewCounts(7)).toEqual({ + visibleCount: 3, + overflowCount: 4, + }); + }); +}); + describe("shouldUseCompactComposerFooter", () => { it("stays expanded without a measured width", () => { expect(shouldUseCompactComposerFooter(null)).toBe(false); @@ -50,3 +73,207 @@ describe("shouldUseCompactComposerPrimaryActions", () => { ).toBe(false); }); }); + +describe("shouldUseRestingComposerLayout", () => { + const resting = { + isExistingThread: true, + isMobileViewport: false, + isFocused: false, + hasExpandedChrome: false, + }; + + it("uses the resting layout for an unfocused desktop composer", () => { + expect(shouldUseRestingComposerLayout(resting)).toBe(true); + }); + + it("keeps new-thread composers expanded", () => { + expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); + }); + + it("leaves responsive mobile on its existing collapse path", () => { + expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false); + }); + + it("expands when focus is anywhere in the composer", () => { + expect(shouldUseRestingComposerLayout({ ...resting, isFocused: true })).toBe(false); + }); + + it("keeps drawers and composer-owned menus expanded", () => { + expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false); + }); +}); + +describe("shouldAnimateComposerRestingTransition", () => { + it("does not animate layout measurements that settle during initial mount", () => { + expect( + shouldAnimateComposerRestingTransition({ + hasCompletedInitialLayout: false, + stateChanged: true, + hasInterruptedAnimation: false, + }), + ).toBe(false); + }); + + it("animates later resting-state changes and interrupted transitions", () => { + expect( + shouldAnimateComposerRestingTransition({ + hasCompletedInitialLayout: true, + stateChanged: true, + hasInterruptedAnimation: false, + }), + ).toBe(true); + expect( + shouldAnimateComposerRestingTransition({ + hasCompletedInitialLayout: true, + stateChanged: false, + hasInterruptedAnimation: true, + }), + ).toBe(true); + }); +}); + +describe("resolveRestingComposerControlsLayout", () => { + // Picker 140 natural / 96 minimum, plus a 9px separator. Traits 60, + // mode 140, overflow 24, gap 4. + const base = { + gap: 4, + naturalFixedWidth: 149, + minimumFixedWidth: 105, + blockWidths: [60, 140], + overflowWidth: 24, + }; + + it("shows everything when the host has room", () => { + // 149 + 60 + 140 + 4 * 2 = 357 + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ + hiddenCount: 0, + visible: true, + }); + }); + + it("moves trailing blocks into the overflow menu until the rest fits", () => { + // 149 + 60 + 24 + 4 * 2 = 241 + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 356 })).toEqual({ + hiddenCount: 1, + visible: true, + }); + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ + hiddenCount: 1, + visible: true, + }); + // 149 + 24 + 4 = 177 + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ + hiddenCount: 2, + visible: true, + }); + }); + + it("shrinks the picker after moving every trailing block into overflow", () => { + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 176 })).toEqual({ + hiddenCount: 2, + visible: true, + }); + // 105 + 24 + 4 = 133 + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 133 })).toEqual({ + hiddenCount: 2, + visible: true, + }); + }); + + it("hides the whole cluster below the picker's minimum readable width", () => { + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 132 })).toEqual({ + hiddenCount: 2, + visible: false, + }); + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 0 })).toEqual({ + hiddenCount: 2, + visible: false, + }); + }); + + it("uses the same thresholds while shrinking and growing", () => { + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ + hiddenCount: 2, + visible: true, + }); + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ + hiddenCount: 1, + visible: true, + }); + }); + + it("supports a single leading control without overflow blocks", () => { + expect( + resolveRestingComposerControlsLayout({ + ...base, + naturalFixedWidth: 140, + minimumFixedWidth: 140, + blockWidths: [], + overflowWidth: 0, + hostWidth: 139, + }), + ).toEqual({ hiddenCount: 0, visible: false }); + }); +}); + +describe("context strip labels and resting composer controls", () => { + // Widths captured from a desktop renderer that crashed with React error + // 185. The strip is 724px wide. Its expanded labels need 327px, and the + // rest of its chrome needs 125px. The composer controls sit in the host + // that takes whatever is left. + const stripWidth = 724; + const labelWidth = 327; + const chromeWidth = 125; + const measurement = { + gap: 4, + naturalFixedWidth: 96.3828125 + 5 + 4, + minimumFixedWidth: 52 + 5 + 4, + blockWidths: [130.6953125, 119.671875], + overflowWidth: 28, + }; + const naturalWidth = resolveRestingComposerControlsNaturalWidth(measurement); + + function hostWidth(compact: boolean): number { + return stripWidth - chromeWidth - (compact ? 0 : labelWidth); + } + + it("keeps the labels compact when the full controls only fit beside compact labels", () => { + // Compact labels leave 599px, so the composer shows every block. + const layout = resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth: hostWidth(true), + }); + expect(layout).toEqual({ hiddenCount: 0, visible: true }); + + // The strip reserves the natural controls width, so expanding the + // labels is off the table: 125 + 327 + 364 > 724. + const compact = resolveContextStripLabelsCompact({ + compact: true, + neededWidth: chromeWidth + labelWidth + naturalWidth, + availableWidth: stripWidth, + }); + expect(compact).toBe(true); + + // The next pass sees the same inputs and lands on the same answer. + expect( + resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }), + ).toEqual(layout); + }); + + it("does not settle when the strip only reserves the visible controls", () => { + // Regression guard for the alternating layout. Reserving only the + // controls left visible after two blocks moved into overflow makes the + // strip expand its labels, which shrinks the host below what the full + // controls need, which hides the blocks again. + const hiddenControlsWidth = 137; + const expands = !resolveContextStripLabelsCompact({ + compact: true, + neededWidth: chromeWidth + labelWidth + hiddenControlsWidth, + availableWidth: stripWidth, + }); + expect(expands).toBe(true); + expect( + resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(false) }), + ).toEqual({ hiddenCount: 2, visible: true }); + }); +}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 5e0b3a8ea379..2ab3b36a1b90 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,5 +1,17 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; +export const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; + +export function getRestingComposerImagePreviewCounts(imageCount: number): { + visibleCount: number; + overflowCount: number; +} { + const visibleCount = Math.min(imageCount, RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT); + return { + visibleCount, + overflowCount: Math.max(0, imageCount - visibleCount), + }; +} export function shouldUseCompactComposerFooter( width: number | null, @@ -11,6 +23,36 @@ export function shouldUseCompactComposerFooter( return width !== null && width < breakpoint; } +export function shouldUseRestingComposerLayout(input: { + isExistingThread: boolean; + isMobileViewport: boolean; + isFocused: boolean; + hasExpandedChrome: boolean; +}): boolean { + // Passive draft content is deliberately absent here. Resting only clamps + // the prompt row and overlays its actions; non-image attachment and context + // rows keep their natural height above it while image previews move inline. + // Banners and the tasks badge dock above the surface, so they are absent + // too. Whether the context strip can host the relocated controls is + // deliberately absent here: resting reclaims vertical space at every + // desktop width, and where the strip is missing or too narrow the controls + // simply return when the composer is focused. + return ( + input.isExistingThread && + !input.isMobileViewport && + !input.isFocused && + !input.hasExpandedChrome + ); +} + +export function shouldAnimateComposerRestingTransition(input: { + hasCompletedInitialLayout: boolean; + stateChanged: boolean; + hasInterruptedAnimation: boolean; +}): boolean { + return input.hasCompletedInitialLayout && (input.stateChanged || input.hasInterruptedAnimation); +} + export function shouldUseCompactComposerPrimaryActions( width: number | null, options?: { hasWideActions?: boolean }, @@ -20,3 +62,64 @@ export function shouldUseCompactComposerPrimaryActions( } return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; } + +export interface RestingComposerControlsMeasurement { + gap: number; + naturalFixedWidth: number; + minimumFixedWidth: number; + blockWidths: readonly number[]; + overflowWidth: number; +} + +function restingComposerControlsWidth( + input: RestingComposerControlsMeasurement, + hiddenCount: number, + fixedWidth = input.naturalFixedWidth, +): number { + const { blockWidths, gap } = input; + const visibleCount = blockWidths.length - hiddenCount; + return ( + fixedWidth + + blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + + (hiddenCount > 0 ? input.overflowWidth : 0) + + gap * (visibleCount + (hiddenCount > 0 ? 1 : 0)) + ); +} + +/** + * The width the resting controls take with nothing moved into overflow. + * + * The context strip reserves this much for the composer before deciding + * whether its own labels may expand. Judging against the currently visible + * controls instead lets the strip expand into space the composer just gave + * up, which shrinks the host, hides the controls again, and repeats. + */ +export function resolveRestingComposerControlsNaturalWidth( + input: RestingComposerControlsMeasurement, +): number { + return restingComposerControlsWidth(input, 0); +} + +/** + * Decide how many trailing resting control blocks move into the overflow + * menu, and whether the cluster can show at all, from natural widths. + * + * Trailing blocks hide before the model picker shrinks. Once they are all in + * the overflow menu, the picker may contract to its minimum readable width; + * below that the whole cluster hides rather than clipping. + */ +export function resolveRestingComposerControlsLayout( + input: RestingComposerControlsMeasurement & { hostWidth: number }, +): { hiddenCount: number; visible: boolean } { + const { blockWidths, hostWidth } = input; + let hiddenCount = 0; + while ( + hiddenCount < blockWidths.length && + restingComposerControlsWidth(input, hiddenCount) > hostWidth + ) { + hiddenCount += 1; + } + const visible = + restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth) <= hostWidth; + return { hiddenCount, visible }; +} diff --git a/apps/web/src/components/composerSelection.test.ts b/apps/web/src/components/composerSelection.test.ts new file mode 100644 index 000000000000..1d53b43b1f7d --- /dev/null +++ b/apps/web/src/components/composerSelection.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { didComposerSelectionChangeVisibly } from "./composerSelection"; + +describe("didComposerSelectionChangeVisibly", () => { + it("ignores selection-neutral editor updates", () => { + expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, null)).toBe(false); + expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 3, end: 3 })).toBe( + false, + ); + }); + + it("detects entering and extending a visible range selection", () => { + expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 0, end: 3 })).toBe( + true, + ); + expect(didComposerSelectionChangeVisibly({ start: 1, end: 3 }, { start: 0, end: 3 })).toBe( + true, + ); + }); + + it("does not repeatedly report the same visible selection", () => { + expect(didComposerSelectionChangeVisibly({ start: 0, end: 3 }, { start: 0, end: 3 })).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/components/composerSelection.ts b/apps/web/src/components/composerSelection.ts new file mode 100644 index 000000000000..642153f0151c --- /dev/null +++ b/apps/web/src/components/composerSelection.ts @@ -0,0 +1,15 @@ +export type ComposerSelectionRange = { + start: number; + end: number; +}; + +export function didComposerSelectionChangeVisibly( + previous: ComposerSelectionRange, + next: ComposerSelectionRange | null, +): boolean { + return ( + next !== null && + next.start !== next.end && + (next.start !== previous.start || next.end !== previous.end) + ); +} diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx new file mode 100644 index 000000000000..3715b62ca15a --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -0,0 +1,186 @@ +import type { GitStatusEntry } from "@pierre/trees"; +import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, type ReactNode } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { cn } from "~/lib/utils"; +import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; + +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + buildDiffFileTreeUpdates, + collectDirectoryPaths, + type DiffFileTreeEntry, +} from "./diffFileTree.logic"; + +export type { DiffFileTreeEntry } from "./diffFileTree.logic"; + +interface DiffFileTreeProps { + readonly entries: ReadonlyArray; + /** Called with the file's path when the reader picks a file row. */ + readonly onSelectFile: (path: string) => void; + /** + * The file the diff is currently showing, kept selected in the tree. Bump `revealRequestId` to + * scroll the tree to the same path again. + */ + readonly selectedPath?: string | null; + readonly revealRequestId?: number; + readonly ariaLabel: string; + /** Right-aligned content in the header row, after the file count. */ + readonly headerAccessory?: ReactNode; + /** Rendered under the tree, for a host that still has files to fetch. */ + readonly footer?: ReactNode; + readonly className?: string; +} + +/** + * A directory tree of the files in a diff. Every directory starts open: a diff is a short list + * compared to a workspace, and the reader came for the files, not the folders. + */ +export function DiffFileTree({ + entries, + onSelectFile, + selectedPath = null, + revealRequestId = 0, + ariaLabel, + headerAccessory, + footer, + className, +}: DiffFileTreeProps) { + const { resolvedTheme } = useTheme(); + const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); + const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); + const gitStatus = useMemo>( + () => entries.map((entry) => ({ path: entry.path, status: entry.status })), + [entries], + ); + const filePathsRef = useRef>(new Set(paths)); + const onSelectFileRef = useRef(onSelectFile); + // Selection driven by `selectedPath` below is an echo of a file already on screen, not a + // request to scroll to it again. + const syncingSelectionRef = useRef(false); + const handledRevealRef = useRef<{ path: string; revealRequestId: number } | null>(null); + const mountedPathsRef = useRef | null>(null); + + useEffect(() => { + filePathsRef.current = new Set(paths); + onSelectFileRef.current = onSelectFile; + }, [onSelectFile, paths]); + + const { model } = useFileTree({ + density: "compact", + flattenEmptyDirectories: true, + initialExpansion: "open", + icons: T3_PIERRE_ICONS, + onSelectionChange: (selectedPaths) => { + if (syncingSelectionRef.current) return; + const path = selectedPaths.at(-1)?.replace(/\/$/, ""); + if (path && filePathsRef.current.has(path)) onSelectFileRef.current(path); + }, + paths: [], + search: false, + unsafeCSS: PIERRE_TREE_UNSAFE_CSS, + }); + const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => + areAllDirectoriesExpanded(currentModel, directoryPaths), + ); + + useEffect(() => { + const mountedPaths = mountedPathsRef.current; + if (mountedPaths === paths) return; + mountedPathsRef.current = paths; + if (mountedPaths === null) { + model.resetPaths(paths); + } else { + const updates = buildDiffFileTreeUpdates(mountedPaths, paths); + if (updates.length > 0) model.batch(updates); + } + model.setGitStatus(gitStatus); + }, [gitStatus, model, paths]); + + useEffect(() => { + if (selectedPath === null) { + handledRevealRef.current = null; + return; + } + // A path list that changes under an already-revealed file (a refresh, a later slice) must + // not pull the tree back to it over whatever the reader has picked since. + const item = model.getItem(selectedPath); + if (item === null || item.isDirectory()) { + // A file that left the diff has to be revealed again when it comes back. + handledRevealRef.current = null; + return; + } + const handled = handledRevealRef.current; + if (handled?.path === selectedPath && handled.revealRequestId === revealRequestId) return; + handledRevealRef.current = { path: selectedPath, revealRequestId }; + syncingSelectionRef.current = true; + for (const path of model.getSelectedPaths()) { + if (path !== selectedPath) model.getItem(path)?.deselect(); + } + let ancestor = ""; + for (const segment of selectedPath.split("/").slice(0, -1)) { + ancestor += `${segment}/`; + const directory = model.getItem(ancestor); + if (directory !== null && "expand" in directory) directory.expand(); + } + item.select(); + model.scrollToPath(selectedPath, { offset: "nearest" }); + queueMicrotask(() => { + syncingSelectionRef.current = false; + }); + // `paths` is a dependency so a file that arrives after it was asked for is still revealed. + }, [model, paths, revealRequestId, selectedPath]); + + return ( +
+
+ Files + {entries.length} + {headerAccessory} + {directoryPaths.length > 0 ? ( + + + setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded) + } + /> + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null} +
+ + {footer} +
+ ); +} diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts new file mode 100644 index 000000000000..d8e24968dcea --- /dev/null +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -0,0 +1,69 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDiffFileTreeUpdates, + collectDirectoryPaths, + diffFileTreeEntries, +} from "./diffFileTree.logic"; + +function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata { + return { type, name: `b/${name}`, prevName: `a/${prevName}` } as FileDiffMetadata; +} + +describe("diffFileTreeEntries", () => { + it("maps each change type to its git status under the file's current path", () => { + expect( + diffFileTreeEntries([ + file("new", "src/a.ts"), + file("deleted", "src/b.ts"), + file("rename-pure", "src/c.ts", "src/old-c.ts"), + file("rename-changed", "src/d.ts", "src/old-d.ts"), + file("change", "README.md"), + ]), + ).toEqual([ + { path: "src/a.ts", status: "added" }, + { path: "src/b.ts", status: "deleted" }, + { path: "src/c.ts", status: "renamed" }, + { path: "src/d.ts", status: "renamed" }, + { path: "README.md", status: "modified" }, + ]); + }); +}); + +describe("collectDirectoryPaths", () => { + it("lists every ancestor once, parents first, with Pierre's trailing slash", () => { + expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([ + "apps/", + "apps/web/", + "apps/web/src/", + ]); + }); +}); + +describe("buildDiffFileTreeUpdates", () => { + it("adds a new file's directories before the file", () => { + expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ + { type: "add", path: "src/" }, + { type: "add", path: "src/lib/" }, + { type: "add", path: "src/lib/a.ts" }, + ]); + }); + + it("removes files before their now-empty directories, deepest first", () => { + expect(buildDiffFileTreeUpdates(["src/lib/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([ + { type: "remove", path: "src/lib/a.ts" }, + { type: "remove", path: "src/lib/", recursive: true }, + ]); + }); + + it("keeps a directory that still holds a file", () => { + expect(buildDiffFileTreeUpdates(["src/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([ + { type: "remove", path: "src/a.ts" }, + ]); + }); + + it("produces nothing when the paths are unchanged", () => { + expect(buildDiffFileTreeUpdates(["src/a.ts"], ["src/a.ts"])).toEqual([]); + }); +}); diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts new file mode 100644 index 000000000000..4535ece8b143 --- /dev/null +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -0,0 +1,93 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { FileTreeBatchOperation, GitStatus } from "@pierre/trees"; + +import { resolveFileDiffPath } from "~/lib/diffRendering"; + +/** One changed file as the tree shows it: its current path and how it changed. */ +export interface DiffFileTreeEntry { + readonly path: string; + readonly status: GitStatus; +} + +function toGitStatus(file: FileDiffMetadata): GitStatus { + switch (file.type) { + case "new": + return "added"; + case "deleted": + return "deleted"; + case "rename-pure": + case "rename-changed": + return "renamed"; + case "change": + return "modified"; + } +} + +/** Maps parsed diff files to tree entries, keeping the diff's own order. */ +export function diffFileTreeEntries( + files: ReadonlyArray, +): ReadonlyArray { + return files.map((file) => ({ path: resolveFileDiffPath(file), status: toGitStatus(file) })); +} + +/** + * Every directory on the way to each file, registered with the trailing slash Pierre uses for + * directory ids. Parents come before children so the tree can add them in order. + */ +export function collectDirectoryPaths(paths: ReadonlyArray): ReadonlyArray { + const directories = new Set(); + for (const path of paths) { + const segments = path.split("/"); + let directory = ""; + for (const segment of segments.slice(0, -1)) { + directory += `${segment}/`; + directories.add(directory); + } + } + return [...directories]; +} + +function pathDepth(path: string): number { + return path.split("/").filter(Boolean).length; +} + +/** + * The adds and removes that turn one set of file paths into another, so a diff that changes + * under the reader (a new slice, a refresh after an agent edit) keeps the directories they + * have already opened or closed instead of rebuilding the tree from scratch. + * + * Directories are removed only once no file needs them; a directory that gains its first file + * is added before that file. + */ +export function buildDiffFileTreeUpdates( + previousPaths: ReadonlyArray, + nextPaths: ReadonlyArray, +): FileTreeBatchOperation[] { + const previousDirectories = new Set(collectDirectoryPaths(previousPaths)); + const nextDirectories = new Set(collectDirectoryPaths(nextPaths)); + const previous = new Set(previousPaths); + const next = new Set(nextPaths); + const updates: FileTreeBatchOperation[] = []; + + for (const path of previousPaths) { + if (!next.has(path)) updates.push({ type: "remove", path }); + } + // Deepest first: a directory can only go once everything under it has. + const removedDirectories = [...previousDirectories] + .filter((directory) => !nextDirectories.has(directory)) + .toSorted((left, right) => pathDepth(right) - pathDepth(left)); + for (const directory of removedDirectories) { + updates.push({ type: "remove", path: directory, recursive: true }); + } + + // Shallowest first: a file's directory has to exist before the file does. + const addedDirectories = [...nextDirectories] + .filter((directory) => !previousDirectories.has(directory)) + .toSorted((left, right) => pathDepth(left) - pathDepth(right)); + for (const directory of addedDirectories) updates.push({ type: "add", path: directory }); + for (const path of nextPaths) { + if (!previous.has(path)) updates.push({ type: "add", path }); + } + + return updates; +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 5e4ce19335a1..dc3cfcef0137 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -19,6 +19,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; @@ -38,18 +39,6 @@ interface FileBrowserPanelProps { workspaceMutationId: string | null; } -const TREE_UNSAFE_CSS = ` - :host { - --trees-bg-override: transparent; - --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent); - --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); - --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); - --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; - } - button[data-type='item'] { border-radius: 5px; } -`; - function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -255,7 +244,7 @@ export default function FileBrowserPanel({ }, paths: [], search: false, - unsafeCSS: TREE_UNSAFE_CSS, + unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => @@ -429,10 +418,7 @@ export default function FileBrowserPanel({ model={model} aria-label={`${projectName} files`} className="min-h-0 flex-1 overflow-hidden" - style={{ - colorScheme: resolvedTheme, - ["--trees-fg-override" as string]: "var(--contrast-foreground)", - }} + style={pierreTreeStyle(resolvedTheme)} /> )}
diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index a67cadbca5a1..cf79c81b9f60 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -1,3 +1,4 @@ +import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import { mediaReferenceFileName, type MediaReference, @@ -68,8 +69,6 @@ export function useMediaActions(source: MediaActionSource) { return { save, copyImage }; } -type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; - /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ export function MediaActions({ source, @@ -82,8 +81,6 @@ export function MediaActions({ const [tooltipOpen, setTooltipOpen] = useState(false); const menuOpen = useRef(false); const reference = source.reference; - const hasActions = - source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); const showMenu = async (position: { x: number; y: number }) => { @@ -94,28 +91,37 @@ export function MediaActions({ let failureTitle = "Could not open media menu"; let progressToast: ReturnType | undefined; try { - const items: ContextMenuItem[] = []; + const noun = source.kind === "image" ? "image" : "video"; + const unavailable = source.src === null && source.asset === undefined; + const canCopyImage = + typeof navigator !== "undefined" && + Boolean(navigator.clipboard?.write) && + typeof ClipboardItem !== "undefined"; + const items: ContextMenuItem[] = []; if (reference?.kind === "file") { - items.push({ id: "copy-full", label: "Copy full path" }); + items.push({ id: "copy-full-path", label: "Copy full path" }); if (reference.relativePath) - items.push({ id: "copy-relative", label: "Copy relative path" }); + items.push({ id: "copy-relative-path", label: "Copy relative path" }); } else if (reference?.kind === "url") { items.push({ id: "copy-url", label: "Copy URL" }); } + if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); if (source.kind === "image") { - const unavailable = source.src === null && source.asset === undefined; - items.push({ id: "save", label: "Save image", disabled: unavailable }); - items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); + items.push({ + id: "copy-image", + label: "Copy image", + disabled: unavailable || !canCopyImage, + }); } - if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); const action = await api.contextMenu.show(items, position); if (!action) return; failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; const text = - action === "copy-full" && reference?.kind === "file" + action === "copy-full-path" && reference?.kind === "file" ? reference.path - : action === "copy-relative" && reference?.kind === "file" + : action === "copy-relative-path" && reference?.kind === "file" ? reference.relativePath : action === "copy-url" && reference?.kind === "url" ? reference.url @@ -131,7 +137,7 @@ export function MediaActions({ } else if (action === "save" || action === "copy-image") { progressToast = toastManager.add({ type: "loading", - title: action === "save" ? "Preparing image download…" : "Copying image…", + title: action === "save" ? `Preparing ${noun} download…` : "Copying image…", }); await (action === "save" ? save() : copyImage()); toastManager.update(progressToast, { @@ -158,7 +164,7 @@ export function MediaActions({ render={children} tabIndex={0} onContextMenu={(event) => { - if (!hasActions || event.defaultPrevented) return; + if (event.defaultPrevented) return; event.preventDefault(); event.stopPropagation(); const bounds = event.currentTarget.getBoundingClientRect(); @@ -170,7 +176,6 @@ export function MediaActions({ }} onKeyDown={(event) => { if ( - !hasActions || event.defaultPrevented || !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) ) diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 8f2d75680c14..f436beeb3855 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -1,4 +1,4 @@ -import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { cn } from "../../lib/utils"; @@ -14,11 +14,13 @@ interface MediaVideoPlayerProps { readonly originalUrl?: string | undefined; readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; + readonly autoPlay?: boolean | undefined; readonly className?: string | undefined; readonly videoClassName?: string | undefined; + /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ + readonly stateClassName?: string | undefined; readonly style?: CSSProperties | undefined; readonly copyMarkdown?: string | undefined; - readonly onExpand?: ((src: string) => void) | undefined; readonly onRetry?: (() => Promise) | undefined; readonly actionsSource?: MediaActionSource | undefined; } @@ -31,11 +33,12 @@ export function MediaVideoPlayer({ originalUrl, revision = null, preload = "visible", + autoPlay = false, className, videoClassName, + stateClassName, style, copyMarkdown, - onExpand, onRetry, actionsSource, }: MediaVideoPlayerProps) { @@ -114,23 +117,6 @@ export function MediaVideoPlayer({ } }; - const expandButton = - onExpand && src !== null ? ( - - ) : null; - const player = ( @@ -159,7 +148,6 @@ export function MediaVideoPlayer({ ) : null} - {expandButton} ) : src !== null ? ( @@ -168,6 +156,7 @@ export function MediaVideoPlayer({ ref={videoRef} src={src} aria-label={label || "Video preview"} + autoPlay={autoPlay} controls playsInline preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} @@ -186,11 +175,10 @@ export function MediaVideoPlayer({ )} - {!failed && expandButton} ); return actionsSource ? {player} : player; diff --git a/apps/web/src/components/preview/PreviewChromeRow.test.tsx b/apps/web/src/components/preview/PreviewChromeRow.test.tsx deleted file mode 100644 index 143d38e67a27..000000000000 --- a/apps/web/src/components/preview/PreviewChromeRow.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { PreviewChromeRow } from "./PreviewChromeRow"; - -describe("PreviewChromeRow", () => { - it("shows the complete URL while the address bar is not focused", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('value="https://example.com/dashboard?mode=edit&tab=1#notes"'); - }); -}); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 9a5656d76a1c..46dd33f7beb4 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -1,11 +1,10 @@ -import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { openTerminalLinkInPreview, - TerminalLinkContextMenuShowError, TerminalLinkPreviewOpenError, } from "./openTerminalLinkInPreview"; @@ -30,6 +29,15 @@ vi.mock("~/browser/browserDefaults", () => ({ browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId, })); +const linkTargetMocks = vi.hoisted(() => ({ + preference: vi.fn<() => "system" | "app">(), +})); + +vi.mock("~/browser/browserLinkTarget", () => ({ + resolveBrowserLinkTargetPreference: async () => linkTargetMocks.preference(), + isWebUrl: (url: string) => /^https?:/u.test(url), +})); + const hydratedDefaults = { viewport: { _tag: "fixed", width: 1280, height: 720 } as const, profileId: "work", @@ -50,7 +58,9 @@ const snapshot: PreviewSessionSnapshot = { }; beforeEach(() => { + browserDefaultsMocks.resolve.mockReset(); browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults); + linkTargetMocks.preference.mockReturnValue("app"); }); afterEach(() => { @@ -58,6 +68,37 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it("opens in the system browser while that is the configured target", async () => { + linkTargetMocks.preference.mockReturnValue("system"); + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openTerminalLinkInPreview({ + url: "http://localhost:3000/", + threadRef, + openPreview, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + }); + + it("opens public URLs in-app too, not only local servers", async () => { + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + }); + + expect(openPreview).toHaveBeenCalledOnce(); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + }); + it("waits for hydrated viewport and profile defaults before opening", async () => { let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined; browserDefaultsMocks.resolve.mockImplementationOnce( @@ -70,14 +111,8 @@ describe("openTerminalLinkInPreview", () => { const opening = openTerminalLinkInPreview({ url: "http://localhost:3000/", - position: { x: 12, y: 34 }, threadRef, openPreview, - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser: vi.fn(), }); @@ -97,42 +132,6 @@ describe("openTerminalLinkInPreview", () => { }); }); - it("preserves context-menu failures with terminal link context before falling back", async () => { - const cause = new Error("menu unavailable"); - const fallbackToBrowser = vi.fn(); - const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); - - await openTerminalLinkInPreview({ - url: "http://localhost:3000/path?token=secret", - position: { x: 12, y: 34 }, - threadRef, - openPreview, - localApi: { - contextMenu: { - show: vi.fn(async () => { - throw cause; - }), - }, - } as unknown as LocalApi, - fallbackToBrowser, - }); - - expect(fallbackToBrowser).toHaveBeenCalledOnce(); - expect(openPreview).not.toHaveBeenCalled(); - expect(reportError).toHaveBeenCalledOnce(); - const error = reportError.mock.calls[0]?.[0]; - expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); - expect(error).toMatchObject({ - environmentId: "local", - threadId: "thread-1", - targetOrigin: "http://localhost:3000", - cause, - }); - expect(error.message).not.toContain("menu unavailable"); - expect(error.targetOrigin).not.toContain("secret"); - }); - it("preserves the complete preview failure cause before falling back", async () => { const rpcError = new Error("preview unavailable"); const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); @@ -141,14 +140,8 @@ describe("openTerminalLinkInPreview", () => { await openTerminalLinkInPreview({ url: "http://127.0.0.1:5173/", - position: { x: 12, y: 34 }, threadRef, openPreview: async () => AsyncResult.failure(cause), - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser, }); @@ -171,14 +164,8 @@ describe("openTerminalLinkInPreview", () => { await openTerminalLinkInPreview({ url: "http://localhost:5173/", - position: { x: 12, y: 34 }, threadRef, openPreview: async () => AsyncResult.failure(Cause.interrupt()), - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser, }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index f5725fc2acfa..4a48403c7e54 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,6 +1,5 @@ -import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; -import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; import { @@ -8,6 +7,7 @@ import { browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -20,15 +20,6 @@ const terminalLinkErrorContext = { cause: Schema.Defect(), }; -export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass()( - "TerminalLinkContextMenuShowError", - terminalLinkErrorContext, -) { - override get message(): string { - return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; - } -} - export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass()( "TerminalLinkPreviewOpenError", terminalLinkErrorContext, @@ -40,20 +31,26 @@ export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass { readonly url: string; - readonly position: { x: number; y: number }; readonly threadRef: ScopedThreadRef; readonly openPreview: OpenPreviewMutation; - readonly localApi: LocalApi; readonly fallbackToBrowser: () => void; } +/** + * Opens a terminal hyperlink where the "Open links in" setting says. Terminal + * links are activated with the platform modifier already held, so unlike chat + * links the modifier cannot double as the system-browser override; the setting + * alone decides, and the system browser is the fallback whenever the in-app + * one cannot take the URL. + */ export async function openTerminalLinkInPreview( input: OpenTerminalLinkInPreviewInput, ): Promise { const supportsPreview = - isPreviewableUrl(input.url) && + isWebUrl(input.url) && isPreviewSupportedInRuntime() && - input.threadRef.threadId.length > 0; + input.threadRef.threadId.length > 0 && + (await resolveBrowserLinkTargetPreference()) === "app"; if (!supportsPreview) { input.fallbackToBrowser(); @@ -66,59 +63,32 @@ export async function openTerminalLinkInPreview( targetOrigin: new URL(input.url).origin, }; - let choice: "open-in-preview" | "open-in-browser" | null; - try { - choice = await input.localApi.contextMenu.show( - [ - { id: "open-in-preview", label: "Open in preview" }, - { id: "open-in-browser", label: "Open in browser" }, - ], - input.position, - ); - } catch (cause) { + const defaults = await resolveBrowserDefaults(); + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Same reason as `openUrlInPreview`: this path handles its own result + // mapping, so the configured defaults are applied explicitly. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } console.error( - new TerminalLinkContextMenuShowError({ + new TerminalLinkPreviewOpenError({ ...errorContext, - cause, + cause: result.cause, }), ); input.fallbackToBrowser(); return; } - - if (choice === "open-in-preview") { - const defaults = await resolveBrowserDefaults(); - const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { - threadId: input.threadRef.threadId, - url: input.url, - // Same reason as `openUrlInPreview`: this path handles its own result - // mapping, so the configured defaults are applied explicitly. - viewport: browserDefaultOpenViewport(defaults), - profileId: browserDefaultOpenProfileId(defaults), - }, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - console.error( - new TerminalLinkPreviewOpenError({ - ...errorContext, - cause: result.cause, - }), - ); - input.fallbackToBrowser(); - return; - } - recordVisitForThread(input.threadRef, input.url); - applyPreviewServerSnapshot(input.threadRef, result.value); - useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); - return; - } - - if (choice === "open-in-browser") { - input.fallbackToBrowser(); - } + recordVisitForThread(input.threadRef, input.url); + applyPreviewServerSnapshot(input.threadRef, result.value); + useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); } diff --git a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx new file mode 100644 index 000000000000..b42061628d1e --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx @@ -0,0 +1,136 @@ +/** + * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a + * scrolling body that says when the list is loading, could not be read, is empty, or is not all + * of it. The rows and the words are the caller's; the frame is the same either way. + */ +import type { ReactNode } from "react"; + +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestPeopleGhost } from "./PullRequestGhosts"; + +export function PullRequestCandidatePicker({ + icon, + label, + allowed, + disabledReason, + open, + onOpenChange, + query, + onQueryChange, + searchLabel, + isPending, + error, + candidates, + emptyLabel, + noMatchLabel, + errorLabel, + truncated, + truncatedLabel, + candidateKey, + disabled, + onSelect, + children, +}: { + icon: ReactNode; + /** The trigger's accessible name; the button carries an icon alone. */ + label: string; + /** False where the host would refuse this account's change. Disabled with the reason rather + * than hidden: a control that vanishes teaches nobody why. */ + allowed: boolean; + disabledReason: string; + open: boolean; + onOpenChange: (open: boolean) => void; + query: string; + onQueryChange: (query: string) => void; + searchLabel: string; + isPending: boolean; + error: string | null; + /** Already narrowed by the query; the shell only decides which state to show. */ + candidates: ReadonlyArray; + emptyLabel: string; + noMatchLabel: string; + /** Leads the host's own message, which follows it in the same sentence. */ + errorLabel: string; + /** The host has more than the read asked for, so a name missing here may still be askable. */ + truncated: boolean; + truncatedLabel: string; + candidateKey: (candidate: T) => string; + /** Every row locks while one change is in flight, so a second press cannot race the first. */ + disabled: boolean; + onSelect: (candidate: T) => void; + children: (candidate: T) => ReactNode; +}) { + if (!allowed) { + return ( + + + {icon} + + } + /> + {disabledReason} + + ); + } + + return ( + + + {icon} + + } + /> + +
+ onQueryChange(event.currentTarget.value)} + placeholder={searchLabel} + aria-label={searchLabel} + size="compact" + /> +
+
+ {isPending ? ( + + ) : error !== null ? ( +

+ {errorLabel} {error} +

+ ) : candidates.length === 0 ? ( +

+ {query.length > 0 ? noMatchLabel : emptyLabel} +

+ ) : ( + candidates.map((candidate) => ( + // Stays open on press: a change is confirmed by the row's own check turning over, + // and a second label or reviewer is usually wanted right after the first. + onSelect(candidate)} + className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs" + > + {children(candidate)} + + )) + )} + {truncated ? ( + // Typing filters what arrived; it does not ask the host again, so this says what the + // list is rather than offering a search that would find nothing further. +

{truncatedLabel}

+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx index 300d3e3062e4..20100339ecbb 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -3,15 +3,17 @@ import type { PullRequestCheck, PullRequestChecksState, PullRequestRef, + ScopedThreadRef, } from "@t3tools/contracts"; -import { readLocalApi } from "~/localApi"; +import { useOpenLink } from "~/browser/useOpenLink"; import { cn } from "~/lib/utils"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import { PullRequestCheckStatusIcon, pullRequestCheckStatusLabel, @@ -27,9 +29,11 @@ import { function LazyChecksBody({ environmentId, reference, + threadRef, }: { environmentId: EnvironmentId; reference: PullRequestRef; + threadRef: ScopedThreadRef | null; }) { const detailQuery = useEnvironmentQuery( pullRequestEnvironment.detail({ environmentId, input: reference }), @@ -44,10 +48,17 @@ function LazyChecksBody({

); } - return ; + return ; } -function ChecksBody({ checks }: { checks: ReadonlyArray }) { +function ChecksBody({ + checks, + threadRef, +}: { + checks: ReadonlyArray; + threadRef: ScopedThreadRef | null; +}) { + const openLink = useOpenLink(threadRef); if (checks.length === 0) { return

No checks reported

; } @@ -71,7 +82,13 @@ function ChecksBody({ checks }: { checks: ReadonlyArray }) { @@ -94,6 +111,7 @@ export function PullRequestChecksPopover({ checks, environmentId, reference, + threadRef = null, className, }: { checksState: PullRequestChecksState; @@ -101,6 +119,8 @@ export function PullRequestChecksPopover({ checks?: ReadonlyArray; environmentId?: EnvironmentId; reference?: PullRequestRef; + /** Thread the popover sits beside; a listing row has none. */ + threadRef?: ScopedThreadRef | null; className?: string; }) { const presentation = pullRequestChecksStatePresentation(checksState); @@ -129,9 +149,13 @@ export function PullRequestChecksPopover({

{presentation.label}

{summary === null ? null :

{summary}

} {checks !== undefined ? ( - + ) : environmentId !== undefined && reference !== undefined ? ( - + ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d9f1e7af716d..cc9bf0f61887 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1,5 +1,5 @@ import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs"; -import type { CodeViewDiffItem } from "@pierre/diffs/react"; +import type { CodeViewDiffItem, CodeViewHandle } from "@pierre/diffs/react"; import type { EnvironmentId, PullRequestDetailView, @@ -16,6 +16,7 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + FolderTreeIcon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, @@ -24,9 +25,11 @@ import { XIcon, } from "lucide-react"; import { useAtomRefresh } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { useClientSettings } from "~/hooks/useSettings"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; @@ -57,6 +60,8 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; +import { DiffFileTree } from "../diffs/DiffFileTree"; +import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; @@ -97,6 +102,8 @@ type ReviewAnnotation = DiffLineAnnotation; /** Commits per press of "Show more" in the scope menu. */ const COMMIT_PAGE_SIZE = 10; +const PULL_REQUEST_FILE_TREE_STORAGE_KEY = "t3code.pullRequestFileTreeOpen"; + /** One answer from the host: a whole number of files, and where the next one carries on. */ interface DiffSlice { /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */ @@ -217,8 +224,14 @@ export function PullRequestCodeTab({ const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); /** Set once the reader has asked for every file at once, until they pick a file apart again. */ const [foldOverride, setFoldOverride] = useState(null); - const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); + const diffLayout = settings.diffLayout; + const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( + PULL_REQUEST_FILE_TREE_STORAGE_KEY, + false, + Schema.Boolean, + ); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -237,6 +250,7 @@ export function PullRequestCodeTab({ readonly slices: ReadonlyArray; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map()); + const viewerRef = useRef | null>(null); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; @@ -543,34 +557,35 @@ export function PullRequestCodeTab({ [items], ); const allFilesCollapsed = areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys); + const fileTreeEntries = useMemo(() => diffFileTreeEntries(files), [files]); + + // A failed slice must not be asked for again on its own. The files already loaded keep the + // sentinel on screen, so re-arming it after a failure would request the same slice forever. + const canLoadNextSlice = + nextCursor !== null && + nextCursor !== cursor && + !diffQuery.isPending && + diffQuery.error === null; + const loadNextSlice = useCallback(() => { + if (nextCursor === null) return; + setSliceState((previous) => ({ ...previous, cursor: nextCursor })); + }, [nextCursor]); // The sentinel is held as state rather than a ref because the viewer mounts its own footer: // an effect reading a ref could run before that node exists and would never arm the observer. const [sentinel, setSentinel] = useState(null); useEffect(() => { - // A failed slice must stop the observer. The files already loaded keep the sentinel on - // screen, so re-arming it after a failure would ask for the same slice again, forever. - if ( - sentinel === null || - nextCursor === null || - nextCursor === cursor || - diffQuery.isPending || - diffQuery.error !== null - ) { - return; - } + if (sentinel === null || !canLoadNextSlice) return; const observer = new IntersectionObserver( (observed) => { - if (observed.some((entry) => entry.isIntersecting)) { - setSliceState((previous) => ({ ...previous, cursor: nextCursor })); - } + if (observed.some((entry) => entry.isIntersecting)) loadNextSlice(); }, // Start the next slice slightly before the sentinel is on screen. { rootMargin: "240px" }, ); observer.observe(sentinel); return () => observer.disconnect(); - }, [cursor, diffQuery.error, diffQuery.isPending, nextCursor, sentinel]); + }, [canLoadNextSlice, loadNextSlice, sentinel]); // A stable identity: the viewer's SlotPortals memoizes each file's header/annotation portal on // these render props, so a fresh function here would recreate every visible file's portal on @@ -588,6 +603,23 @@ export function PullRequestCodeTab({ [], ); + // Held as state so the scroll runs after a folded file has been drawn open; scrolling in the + // same tick would land on the folded header's position. + const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null); + useEffect(() => { + if (treeReveal === null) return; + viewerRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" }); + }, [treeReveal]); + const revealFile = useCallback( + (path: string) => { + const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path); + if (item === undefined) return; + if (item.collapsed === true) toggleFile(item.id); + setTreeReveal((current) => ({ fileKey: item.id, id: (current?.id ?? 0) + 1 })); + }, + [items, toggleFile], + ); + const toggleAllFiles = () => { // Held as an override of the default rather than as the file keys on screen: a diff that is // still paging would otherwise bring its next slice in folded, moments after the reader @@ -740,7 +772,7 @@ export function PullRequestCodeTab({ const diffViewOptions = useMemo( () => ({ - diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const), + diffStyle: diffLayout === "split" ? ("split" as const) : ("unified" as const), lineDiffType: "none" as const, overflow: wordWrap ? ("wrap" as const) : ("scroll" as const), theme: resolveDiffThemeName(resolvedTheme), @@ -757,15 +789,7 @@ export function PullRequestCodeTab({ onGutterUtilityClick: beginComment, onLineSelectionEnd: beginComment, }), - [ - diffRenderMode, - wordWrap, - resolvedTheme, - loadDiffFiles, - canCommentOnLines, - draft, - beginComment, - ], + [diffLayout, wordWrap, resolvedTheme, loadDiffFiles, canCommentOnLines, draft, beginComment], ); const runThreadCommand = useCallback( @@ -1122,11 +1146,11 @@ export function PullRequestCodeTab({ { const next = value[0]; if (next === "stacked" || next === "split") { - setDiffRenderMode(next); + updateClientSettings({ diffLayout: next }); } }} > @@ -1157,6 +1181,26 @@ export function PullRequestCodeTab({ {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + {fileKeys.length > 0 ? ( + + setFileTreeOpen(Boolean(pressed))} + /> + } + > + + + + {fileTreeOpen ? "Hide file tree" : "Show file tree"} + + + ) : null} ); @@ -1312,58 +1356,94 @@ export function PullRequestCodeTab({ ) : null} - {/* Relative wrapper so the review overlay floats over the diff rather than pushing it +
+ {/* Relative wrapper so the review overlay floats over the diff rather than pushing it up; the viewer inside still owns its own scrolling. */} -
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - for (const node of composedPath) { - if (!(node instanceof HTMLElement)) continue; - // A control inside the header — the collapse chevron — handles itself, and - // this capture listener fires before its own click does. Leave it alone or - // the two toggles cancel out. - if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { - return; - } - if (node.hasAttribute("data-diffs-header")) { - const filePath = node.querySelector("[data-title]")?.textContent?.trim(); - if (filePath === undefined || filePath === "") return; - const item = items.find( - (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, - ); - if (item !== undefined) toggleFile(item.id); - return; +
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // A control inside the header — the collapse chevron — handles itself, and + // this capture listener fires before its own click does. Leave it alone or + // the two toggles cancel out. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + if (node.hasAttribute("data-diffs-header")) { + const filePath = node.querySelector("[data-title]")?.textContent?.trim(); + if (filePath === undefined || filePath === "") return; + const item = items.find( + (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, + ); + if (item !== undefined) toggleFile(item.id); + return; + } } - } - }} - > - {/* The viewer virtualizes against the element it is told is scrolling and places its + }} + > + {/* The viewer virtualizes against the element it is told is scrolling and places its rows absolutely, so it has to own that element — the thread diff panel hands it the same one. Scrolling from a parent instead leaves it painting over its neighbours. */} - - // Keep scrollbar space stable so file metadata and line numbers do not shift as a - // diff crosses the overflow boundary. The viewer is itself focusable for keyboard - // interaction, but its native host outline clips and competes with the focus - // indicators on its actual controls. - className="h-full overflow-auto [scrollbar-gutter:stable]" - items={items} - selectedLines={selectedLines} - onSelectedLinesChange={setSelectedLines} - options={diffViewOptions} - // The viewer owns the scroll container, so the sentinel that asks for the next slice - // has to live inside it — at the end of the files, where reaching it means the reader - // is running out of diff. - renderCodeViewFooter={renderCodeViewFooter} - renderHeaderPrefix={renderHeaderPrefix} - renderHeaderMetadata={renderHeaderMetadata} - renderAnnotation={renderAnnotation} - unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} - /> - {reviewOverlay} + + // Keep scrollbar space stable so file metadata and line numbers do not shift as a + // diff crosses the overflow boundary. The viewer is itself focusable for keyboard + // interaction, but its native host outline clips and competes with the focus + // indicators on its actual controls. + className="h-full overflow-auto [scrollbar-gutter:stable]" + viewerRef={viewerRef} + items={items} + selectedLines={selectedLines} + onSelectedLinesChange={setSelectedLines} + options={diffViewOptions} + // The viewer owns the scroll container, so the sentinel that asks for the next slice + // has to live inside it — at the end of the files, where reaching it means the reader + // is running out of diff. + renderCodeViewFooter={renderCodeViewFooter} + renderHeaderPrefix={renderHeaderPrefix} + renderHeaderMetadata={renderHeaderMetadata} + renderAnnotation={renderAnnotation} + unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} + /> + {reviewOverlay} +
+ {fileTreeOpen ? ( +
+ ) + } + /> + + ) : null}
{unstructured} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index a109b1614382..59aa1333896b 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,13 +1,14 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { - EnvironmentId, - PullRequestAction, - PullRequestMergeMethod, - PullRequestUpdateMethod, - PullRequestRef, - PullRequestState, - ScopedThreadRef, +import { + type EnvironmentId, + type PullRequestAction, + type PullRequestMergeMethod, + type PullRequestUpdateMethod, + type PullRequestRef, + type PullRequestState, + resolveEnvironmentMachineKind, + type ScopedThreadRef, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -36,7 +37,6 @@ import { PlayIcon, RefreshCwIcon, RotateCcwIcon, - ServerIcon, TriangleAlertIcon, } from "lucide-react"; import { @@ -63,7 +63,7 @@ import { useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { pullRequestEnvironment } from "~/state/pullRequests"; +import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -77,6 +77,7 @@ import { AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -120,10 +121,13 @@ import { pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, + readPullRequestDetailSnapshot, + resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, resolveBaseFreshness, type PullRequestFinding, shouldRefreshPullRequestActivity, + writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; import { @@ -269,7 +273,10 @@ function ActOnEnvironmentPicker({ {/* The radio item lays its children out as one block, so the icon and the label need their own row to share a line. */} - + {environment.label} @@ -438,6 +445,7 @@ function PullRequestBaseFreshnessWarning({ export function PullRequestDetailPanel({ environmentId, + threadRef = null, reference, refreshToken: forcedRefreshToken = 0, onActed, @@ -447,6 +455,13 @@ export function PullRequestDetailPanel({ composerDraftTarget, }: { environmentId: EnvironmentId; + /** + * The thread this panel sits beside, if any. Links that are not the pull + * request itself (check details, host permalinks) can open in that thread's + * in-app browser when the user has asked for it; the page has no thread, so + * there they always go to the system browser. + */ + threadRef?: ScopedThreadRef | null; reference: PullRequestRef; /** * Bumped by whatever holds the panel when a reader asks for everything on screen to be read @@ -461,14 +476,8 @@ export function PullRequestDetailPanel({ onActed?: () => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; - /** Keeps surrounding thread state in step with refreshed host state. */ - onStateChange?: (status: { - projectId: string; - repository: string; - number: number; - state: PullRequestState; - isDraft: boolean; - }) => void; + /** Keeps surrounding inferred thread state in step with refreshed host state. */ + onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; /** * Beside a thread, the checkout affordance disappears: the panel is showing that thread's * own pull request, so the branch is already under the reader's feet — and checking it out @@ -558,7 +567,52 @@ export function PullRequestDetailPanel({ const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); - const coreDetail = detailQuery.data; + const [cachedDetail, setCachedDetail] = useState(() => + readPullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + ), + ); + useEffect(() => { + setCachedDetail( + readPullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + ), + ); + }, [environmentId, pullRequestKey, reference.projectId, reference.repository, reference.number]); + useEffect(() => { + if (detailQuery.data === null) return; + writePullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + detailQuery.data, + ); + setCachedDetail(detailQuery.data); + }, [ + detailQuery.data, + environmentId, + pullRequestKey, + reference.projectId, + reference.repository, + reference.number, + ]); + const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ + live: detailQuery.data, + cached: cachedDetail, + reference, + }); + const sharedSummary = useSharedPullRequestSummary(environmentId, reference, resolvedCoreDetail); + const coreDetail = useMemo( + () => + resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail + ? resolvedCoreDetail + : { ...resolvedCoreDetail, ...sharedSummary }, + [resolvedCoreDetail, sharedSummary], + ); const activity = activityQuery.data; const detail = useMemo( () => @@ -622,16 +676,14 @@ export function PullRequestDetailPanel({ } activityRevision.current = next; }, [activityQuery.refresh, coreDetail, pullRequestKey]); - useEffect(() => { - if (!detail) return; + useLayoutEffect(() => { + if (!resolvedCoreDetail) return; onStateChange?.({ - projectId: detail.projectId, - repository: detail.repository, - number: detail.number, - state: detail.state, - isDraft: detail.isDraft, + repository: resolvedCoreDetail.repository, + number: resolvedCoreDetail.number, + state: resolvedCoreDetail.state, }); - }, [detail, onStateChange]); + }, [onStateChange, resolvedCoreDetail]); // Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the // revision effect above reads it only after this same pull request reports a change. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull @@ -689,7 +741,11 @@ export function PullRequestDetailPanel({ ? resolvePickableEnvironments( { environmentId, projectId: reference.projectId }, projects, - environments, + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + })), ) : [], [context, environmentId, environments, projects, reference.projectId], @@ -1240,14 +1296,16 @@ export function PullRequestDetailPanel({ ).length : 0; + // A reopen already has last time's title, author, and counts. Keep them on screen + // and let the live read replace fields — especially the diff counts — in place. if (detailQuery.isPending && !detail) { return ; } return (
-
-
+
+
-
+
{detail ? ( <> {/* Checking a pull request out is the reason to open one here at all, so it is a @@ -1365,9 +1423,17 @@ export function PullRequestDetailPanel({ + } @@ -1403,17 +1469,39 @@ export function PullRequestDetailPanel({
) : null} {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( - + + + +
+ } + /> + + {pendingAction === "approve-workflows" + ? "Approving..." + : "Approve workflows to run"} + + ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} @@ -1421,62 +1509,150 @@ export function PullRequestDetailPanel({ + - {armedAutoMergeLabel} + {armedAutoMergeLabel} } /> - The host will merge this on its own once its requirements are met + {armedAutoMergeLabel}: the host will merge this on its own once its requirements + are met ) : null} {primaryAction === "resolve" ? ( - + + + +
+ } + /> + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} + + ) : primaryAction === "ready" ? ( - + + + + + } + /> + Ready for review + ) : primaryAction === "enable-auto-merge" ? ( - + + + + + } + /> + + {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel} + + ) : primaryAction === "auto-merge-armed" ? ( - - {armedAutoMergeLabel} + + + {armedAutoMergeLabel} } /> - The host will merge this on its own once its requirements are met + {armedAutoMergeLabel}: the host will merge this on its own once its requirements + are met ) : primaryAction === "merge" ? ( - + + + + + } + /> + + {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + + ) : (primaryAction === "merged" || primaryAction === "closed") && statePresentation !== null ? ( @@ -1807,10 +1983,17 @@ export function PullRequestDetailPanel({ {detail ? (
{titleDraft === null ? ( -
-

- {detail.title} -

+
+ + + {detail.title} + + } + /> + {detail.title} + {canEditPullRequestChangeRequest(detail) ? ( - } - /> - - Asking someone to review needs write access on this repository - - - ); - } - return ( - - - - - } - /> - -
- setQuery(event.currentTarget.value)} - placeholder="Search people with access" - aria-label="Search people with access" - size="compact" - /> -
-
- {candidatesQuery.isPending ? ( - - ) : candidatesQuery.error !== null ? ( -

- The people with access could not be read. {candidatesQuery.error} -

- ) : candidates.length === 0 ? ( -

- {query.length > 0 - ? "Nobody with access matches that." - : "Nobody else has access to this repository."} -

- ) : ( - candidates.map((candidate) => ( - - )) - )} - {candidatesQuery.data?.truncated ? ( - // Typing filters what arrived; it does not ask the host again, so this says what the - // list is rather than offering a search that would find nothing further. -

- This repository has more people with access than are listed here. Ask for the rest on - the host. -

+ } + label="Request a review" + allowed={allowed} + disabledReason="Asking someone to review needs write access on this repository" + open={open} + onOpenChange={setOpen} + query={query} + onQueryChange={setQuery} + searchLabel="Search people with access" + isPending={candidatesQuery.isPending} + error={candidatesQuery.error} + candidates={candidates} + emptyLabel="Nobody else has access to this repository." + noMatchLabel="Nobody with access matches that." + errorLabel="The people with access could not be read." + truncated={candidatesQuery.data?.truncated === true} + truncatedLabel="This repository has more people with access than are listed here. Ask for the rest on the host." + candidateKey={(candidate) => `${candidate.kind}:${candidate.id}`} + disabled={pending !== null} + onSelect={(candidate) => void toggle(candidate)} + > + {(candidate) => ( + <> + + {candidate.kind === "team" ? ( + team + ) : null} + {candidate.isRequested ? ( + ) : null} -
-
-
+ + )} + ); } diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 2284144d7fd1..6ee686a9de7e 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -16,19 +16,46 @@ import { PullRequestStateGlyph, } from "./pullRequestPresentation"; +/** + * Each slot past the first only appears once the meta line is wide enough to hold it, so a + * narrow row shows one label and a "+N" while a wide one spreads out up to three. The "+N" + * rides on whichever pill is the last visible one, and is hidden as soon as the next slot shows. + */ +const LABEL_SLOTS = [ + { pill: "", overflow: "@xl/pr-row-meta:hidden" }, + { pill: "hidden @xl/pr-row-meta:inline-flex", overflow: "@3xl/pr-row-meta:hidden" }, + { pill: "hidden @3xl/pr-row-meta:inline-flex", overflow: "" }, +] as const; + function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) { - const label = labels[0]; - if (!label) return null; - const dot = pullRequestLabelColor(label.color); + if (labels.length === 0) return null; return ( - - - {label.name} - {labels.length > 1 ? +{labels.length - 1} : null} + + {LABEL_SLOTS.map((slot, index) => { + const label = labels[index]; + if (!label) return null; + const dot = pullRequestLabelColor(label.color); + const remaining = labels.length - index - 1; + return ( + + + {label.name} + {remaining > 0 ? ( + +{remaining} + ) : null} + + ); + })} ); } diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 5a3144dadce9..b06630f3bd04 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -4,6 +4,7 @@ import type { PullRequestComment, PullRequestDetailView, PullRequestRef, + ScopedThreadRef, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -23,7 +24,7 @@ import { useRef, useState, type ReactNode } from "react"; import { useAtomCommand } from "~/state/use-atom-command"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { cn } from "~/lib/utils"; -import { readLocalApi } from "~/localApi"; +import { useOpenLink } from "~/browser/useOpenLink"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button } from "../ui/button"; @@ -41,6 +42,7 @@ import { pullRequestReviewOutcomeRingClassName, pullRequestReviewOutcomeStaleLabel, } from "./pullRequestPresentation"; +import { PullRequestLabelPicker } from "./PullRequestLabelPicker"; import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { @@ -92,6 +94,7 @@ function reviewStateLabel(state: string): string { interface CommentEditing { readonly cwd: string; readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef | null; readonly canEdit: (comment: PullRequestComment) => boolean; readonly editingId: string | null; readonly saving: boolean; @@ -119,6 +122,7 @@ function CommentBody({ value={comment.body} cwd={editing.cwd} environmentId={editing.environmentId} + threadRef={editing.threadRef} label="Edit comment" saving={editing.saving} onSave={(body) => editing.onSave(comment, body)} @@ -133,6 +137,7 @@ function CommentBody({ text={comment.body} cwd={editing.cwd} environmentId={editing.environmentId} + threadRef={editing.threadRef} /> {editing.canEdit(comment) ? ( + ) : null; // Read from the whole conversation, not the window shown below it: a verdict older than the // last thirty comments still stands. const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits); @@ -507,8 +526,12 @@ export function PullRequestSummaryTab({ ), ); + const openLink = useOpenLink(threadRef); const openCheck = (url: string) => { - void readLocalApi()?.shell.openExternal(url); + void openLink(url).catch((error: unknown) => { + console.error(error); + toastManager.add({ type: "error", title: "Unable to open check details" }); + }); }; const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); @@ -546,6 +569,7 @@ export function PullRequestSummaryTab({ const commentEditing: CommentEditing = { cwd: detail.workspaceRoot, environmentId, + threadRef, canEdit: (comment) => canEditPullRequestComment(detail, comment), editingId: editingCommentId, saving: commentSaving, @@ -664,25 +688,39 @@ export function PullRequestSummaryTab({ ) : null} - {detail.labels.length > 0 ? ( + {/* The row is shown empty only where a label could be put on it from here; on a host + with none to offer, an empty row is a row about nothing. */} + {detail.labels.length > 0 || detail.capabilities.labels === true ? ( } label="Labels"> - {detail.labels.map((label) => { - const dot = pullRequestLabelColor(label.color); - return ( - + {detail.labels.length === 0 ? ( + None + ) : ( + detail.labels.map((label) => { + const dot = pullRequestLabelColor(label.color); + return ( - {label.name} - - ); - })} + key={label.name} + className="inline-flex max-w-48 items-center gap-1.5 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs" + > + + {label.name} + + ); + }) + )} + {detail.capabilities.labels === true ? ( + + ) : null} ) : null} @@ -707,6 +745,7 @@ export function PullRequestSummaryTab({ value={detail.body} cwd={detail.workspaceRoot} environmentId={environmentId} + threadRef={threadRef} label="Pull request description" placeholder="Describe this pull request" saving={bodySaving} @@ -720,6 +759,7 @@ export function PullRequestSummaryTab({ text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"} cwd={detail.workspaceRoot} environmentId={environmentId} + threadRef={threadRef} /> {canEditPullRequestChangeRequest(detail) ? ( - ) : null} + {commentOrder === "oldest" ? showOldestCommentsButton : null} {visibleComments.map((comment) => { const thread = threadByCommentId.get(comment.id); const body = visibleBody(comment.body); @@ -963,6 +988,7 @@ export function PullRequestSummaryTab({ ); })} + {commentOrder === "newest" ? showOldestCommentsButton : null}
)} diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index c3c8185ab102..946f3cdbc54f 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -4,6 +4,7 @@ import type { PullRequestComment, PullRequestDetailView, PullRequestRef, + ScopedThreadRef, } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -55,6 +56,8 @@ import { interface ReactionSurface { readonly canReact: boolean; readonly environmentId: EnvironmentId; + /** Thread the timeline is shown beside, so body links can open in its in-app browser. */ + readonly threadRef: ScopedThreadRef | null; readonly reference: PullRequestRef; readonly onRefresh: () => void; } @@ -64,16 +67,23 @@ function TimelineBody({ markdown, cwd, environmentId, + threadRef, }: { body: string; markdown: boolean; cwd: string; environmentId: EnvironmentId; + threadRef: ScopedThreadRef | null; }) { return (
{markdown ? ( - + ) : (

{body}

)} @@ -246,6 +256,7 @@ function ConversationCard({ value={editable.body} cwd={cwd} environmentId={reactions.environmentId} + threadRef={reactions.threadRef} label="Edit comment" saving={saving} onSave={(body) => void save(body)} @@ -259,6 +270,7 @@ function ConversationCard({ markdown={event.markdown} cwd={cwd} environmentId={reactions.environmentId} + threadRef={reactions.threadRef} />
) : null} @@ -526,6 +538,7 @@ function ReviewVerdictEvent({ markdown={event.markdown} cwd={cwd} environmentId={reactions.environmentId} + threadRef={reactions.threadRef} /> ) : null}
@@ -538,6 +551,7 @@ function ReviewVerdictEvent({ export function PullRequestTimelineTab({ detail, environmentId, + threadRef = null, reference, order, onOpenCommit, @@ -545,6 +559,7 @@ export function PullRequestTimelineTab({ }: { detail: PullRequestDetailView; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | null; reference: PullRequestRef; order: "newest" | "oldest"; onOpenCommit: (oid: string) => void; @@ -555,6 +570,7 @@ export function PullRequestTimelineTab({ const reactions: ReactionSurface = { canReact: detail.capabilities.reactions === true, environmentId, + threadRef, reference, onRefresh, }; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 348cbd477891..c51429ff6d83 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -2,6 +2,7 @@ import { PullRequestAction, type PullRequestCheck, type PullRequestComment, + type PullRequestDetail, type PullRequestDetailView, type PullRequestReviewThread, } from "@t3tools/contracts"; @@ -31,12 +32,15 @@ import { pullRequestHandoffLabels, pullRequestReviewOutcome, readableFailure, + readPullRequestDetailSnapshot, + resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, describePullRequestState, editPullRequestThreadComment, + writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; import type { ReviewCommentContext } from "~/reviewCommentContext"; @@ -1317,3 +1321,103 @@ describe("which actions need the host read again after they run", () => { } }); }); + +describe("cached pull request detail", () => { + const reference = { projectId: "project-1", repository: "acme/web", number: 7 }; + const detail = (overrides: Partial = {}): PullRequestDetail => + ({ + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + projectId: "project-1", + projectTitle: "web", + workspaceRoot: "/repo", + repository: "acme/web", + number: 7, + title: "Cache the title", + body: "who made it", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: null, avatarUrl: "https://avatars.example/octocat" }, + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 12, + deletions: 3, + changedFiles: 2, + headBranch: "feat/cache", + baseBranch: "main", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + ...overrides, + }) as PullRequestDetail; + + const makeStorage = () => { + const held = new Map(); + return { + getItem: (key: string) => held.get(key) ?? null, + setItem: (key: string, value: string) => void held.set(key, value), + }; + }; + + it("hydrates the last title, author, and counts so a reopen does not ghost the tab", () => { + const storage = makeStorage(); + writePullRequestDetailSnapshot(storage, "env-1", reference, detail()); + const snapshot = readPullRequestDetailSnapshot(storage, "env-1", reference); + expect(snapshot?.title).toBe("Cache the title"); + expect(snapshot?.author?.login).toBe("octocat"); + expect(snapshot?.additions).toBe(12); + expect(snapshot?.deletions).toBe(3); + }); + + it("keeps a cached tab painted while the live read replaces the counts", () => { + const cached = detail(); + const live = detail({ additions: 40, deletions: 9, title: "Cache the title" }); + expect(resolveDisplayedPullRequestDetail({ live, cached, reference })?.additions).toBe(40); + expect(resolveDisplayedPullRequestDetail({ live: null, cached, reference })?.additions).toBe( + 12, + ); + }); + + it("does not paint another change request's snapshot", () => { + expect( + resolveDisplayedPullRequestDetail({ + live: null, + cached: detail({ number: 8 }), + reference, + }), + ).toBeNull(); + expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull(); + }); + + it("shrugs off corrupt storage and no storage at all", () => { + const storage = makeStorage(); + storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); + expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull(); + expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 755bcdf52441..cffe33f8d83d 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,19 +1,22 @@ -import type { - PullRequestAction, - PullRequestActor, - PullRequestBaseComparison, - PullRequestCheck, - PullRequestChecksState, - PullRequestComment, - PullRequestCommit, - PullRequestDetailView, - PullRequestMergeability, - PullRequestReaction, - PullRequestReviewThread, - PullRequestState, - PullRequestUpdateMethod, - SourceControlProviderKind, - VcsRef, +import * as Schema from "effect/Schema"; + +import { + PullRequestDetail, + type PullRequestAction, + type PullRequestActor, + type PullRequestBaseComparison, + type PullRequestCheck, + type PullRequestChecksState, + type PullRequestComment, + type PullRequestCommit, + type PullRequestDetailView, + type PullRequestMergeability, + type PullRequestReaction, + type PullRequestReviewThread, + type PullRequestState, + type PullRequestUpdateMethod, + type SourceControlProviderKind, + type VcsRef, } from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; @@ -1009,3 +1012,75 @@ const ACTION_NEEDS_HOST_REFRESH: Record = { export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean { return ACTION_NEEDS_HOST_REFRESH[action]; } + +type SnapshotStorage = Pick; + +export interface PullRequestDetailSnapshotRef { + readonly projectId: string; + readonly repository: string; + readonly number: number; +} + +const pullRequestDetailSnapshotKey = ( + environmentId: string, + reference: PullRequestDetailSnapshotRef, +) => + `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; + +const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail); + +/** + * The last detail answered for this change request, brought back across a reload. The registry + * the queries live in is recreated with the renderer, so without this a reopen cold-starts + * into a full-tab ghost even though the title, author, and the rest barely moved. Hydrated, + * the chrome stays and the live read replaces fields in place — line counts included. + */ +export function readPullRequestDetailSnapshot( + storage: SnapshotStorage | undefined, + environmentId: string, + reference: PullRequestDetailSnapshotRef, +): PullRequestDetail | null { + try { + const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)); + if (!raw) return null; + const decoded = decodeDetailSnapshot(JSON.parse(raw)); + return decoded._tag === "Some" ? decoded.value : null; + } catch { + return null; + } +} + +export function writePullRequestDetailSnapshot( + storage: SnapshotStorage | undefined, + environmentId: string, + reference: PullRequestDetailSnapshotRef, + detail: PullRequestDetail, +): void { + try { + storage?.setItem( + pullRequestDetailSnapshotKey(environmentId, reference), + JSON.stringify(detail), + ); + } catch { + // Quota or a private-mode store: the next open waits on the live read, which is the + // cold start this snapshot exists to avoid, not a failure of its own. + } +} + +/** Live host state wins; a snapshot is only the same change request, never a neighbour's. */ +export function resolveDisplayedPullRequestDetail(input: { + readonly live: PullRequestDetail | null; + readonly cached: PullRequestDetail | null; + readonly reference: PullRequestDetailSnapshotRef; +}): PullRequestDetail | null { + if (input.live !== null) return input.live; + if ( + input.cached !== null && + input.cached.projectId === input.reference.projectId && + input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() && + input.cached.number === input.reference.number + ) { + return input.cached; + } + return null; +} diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts index aea26286f866..d52b72ca395c 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, EnvironmentMachineKind, ProjectId } from "@t3tools/contracts"; /** The little of a project this needs: who holds it, and which repository it is a copy of. */ export interface AssignableProject { @@ -69,6 +69,7 @@ export interface PickableEnvironment { readonly projectId: ProjectId; readonly workspaceRoot: string; readonly label: string; + readonly machine?: EnvironmentMachineKind; } /** @@ -85,17 +86,21 @@ export interface PickableEnvironment { export function resolvePickableEnvironments( current: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, projects: ReadonlyArray, - environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>, + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + readonly machine?: EnvironmentMachineKind; + }>, ): ReadonlyArray { const own = projects.find( (project) => project.environmentId === current.environmentId && project.id === current.projectId, ); const key = own === undefined ? undefined : repositoryKey(own); - const ownLabel = environments.find( + const ownEnvironment = environments.find( (environment) => environment.environmentId === current.environmentId, - )?.label; - if (own === undefined || !key || ownLabel === undefined) return []; + ); + if (own === undefined || !key || ownEnvironment === undefined) return []; const others = environments.flatMap((environment) => { if (environment.environmentId === current.environmentId) return []; // One entry per server, whichever copy comes first: a server holding two worktrees of the @@ -112,6 +117,7 @@ export function resolvePickableEnvironments( projectId: copy.id, workspaceRoot: copy.workspaceRoot, label: environment.label, + ...(environment.machine === undefined ? {} : { machine: environment.machine }), }, ]; }); @@ -123,7 +129,8 @@ export function resolvePickableEnvironments( environmentId: current.environmentId, projectId: own.id, workspaceRoot: own.workspaceRoot, - label: ownLabel, + label: ownEnvironment.label, + ...(ownEnvironment.machine === undefined ? {} : { machine: ownEnvironment.machine }), }, ...others, ]; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 4c7439482cbe..68f80dc712dc 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,12 +1,16 @@ -import { - ChevronsLeftRightEllipsisIcon, - PlusIcon, - QrCodeIcon, - RefreshCwIcon, - TerminalIcon, -} from "lucide-react"; +import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { + type KeyboardEvent, + type ReactNode, + memo, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -27,6 +31,7 @@ import { type DesktopServerExposureState, type DesktopWslState, type EnvironmentId, + resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { @@ -53,7 +58,17 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; import { Input } from "../ui/input"; +import { CommandShortcut } from "../ui/command"; +import { + Autocomplete, + AutocompleteEmpty, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompletePopup, +} from "../ui/autocomplete"; import { Checkbox } from "../ui/checkbox"; import { Dialog, @@ -86,6 +101,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { AnimatedHeight } from "../AnimatedHeight"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; import { readHostedPairingRequest } from "../../hostedPairing"; @@ -120,7 +136,7 @@ import { desktopNetworkAccessStateAtom, refreshDesktopNetworkAccessState, } from "~/state/desktopNetworkAccess"; -import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; +import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; import { type EnvironmentPresentation, @@ -128,11 +144,17 @@ import { usePrimaryEnvironment, } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; -import { serverEnvironment } from "~/state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, +} from "../../keybindings"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; @@ -1438,6 +1460,11 @@ function SavedBackendListRow({ : null } /> +

{environment.label}

@@ -1445,6 +1472,15 @@ function SavedBackendListRow({ {metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

) : null} + {isConnected ? ( +
+ +
+ ) : null} {serverUpdateState.status !== "idle" ? (
@@ -1544,46 +1580,6 @@ function SavedBackendListRow({ ); } -interface DesktopSshHostRowProps { - target: DesktopDiscoveredSshHost; - connectingHostAlias: string | null; - onConnect: (target: DesktopDiscoveredSshHost) => void; -} - -const DesktopSshHostRow = memo(function DesktopSshHostRow({ - target, - connectingHostAlias, - onConnect, -}: DesktopSshHostRowProps) { - const address = formatDesktopSshTarget(target); - const showAddress = address !== target.alias; - const buttonLabel = connectingHostAlias === target.alias ? "Adding…" : "Add environment"; - - return ( -
-
-
-

{target.alias}

- {showAddress ?

{address}

: null} -
-
- -
-
-
- ); -}); - function CloudLinkSwitch({ checked, disabled, @@ -1752,6 +1748,7 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); @@ -1775,24 +1772,6 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); - const savedDesktopSshEnvironmentsByAlias = useMemo( - () => - savedEnvironments.reduce>( - (accumulator, environment) => { - const profile = environment.entry.profile; - if ( - environment.entry.target._tag === "SshConnectionTarget" && - Option.isSome(profile) && - profile.value._tag === "SshConnectionProfile" - ) { - accumulator[profile.value.target.alias] = environment; - } - return accumulator; - }, - {}, - ), - [savedEnvironments], - ); const savedDesktopSshEnvironmentKeys = useMemo(() => { const keys = new Set(); for (const environment of savedEnvironments) { @@ -1810,9 +1789,6 @@ export function ConnectionsSettings() { } return keys; }, [savedEnvironments]); - const [sshConnectionError, setSshConnectionError] = useState(null); - const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); - const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< string | null >(null); @@ -1833,6 +1809,9 @@ export function ConnectionsSettings() { const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); + const [sshHostSuggestionsOpen, setSshHostSuggestionsOpen] = useState(false); + // Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text. + const highlightedSshHostRef = useRef(undefined); const [savedBackendError, setSavedBackendError] = useState(null); const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = @@ -1898,11 +1877,17 @@ export function ConnectionsSettings() { const desktopNetworkAccess = useEnvironmentQuery( canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, ); + const isSshDiscoveryActive = + desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh"; const desktopSshHosts = useEnvironmentQuery( - desktopBridge && addBackendDialogOpen && savedBackendMode === "ssh" - ? desktopSshHostsStateAtom - : null, + isSshDiscoveryActive ? desktopSshHostsStateAtom : null, ); + // The discovery atom is kept alive across dialog opens, so re-read SSH config + // each time the SSH tab is shown; stale hosts stay visible while it refreshes. + const refreshDesktopSshHosts = desktopSshHosts.refresh; + useEffect(() => { + if (isSshDiscoveryActive) refreshDesktopSshHosts(); + }, [isSshDiscoveryActive, refreshDesktopSshHosts]); const desktopWsl = useEnvironmentQuery( canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null, ); @@ -1921,10 +1906,15 @@ export function ConnectionsSettings() { }), [discoveredSshHosts, savedDesktopSshEnvironmentKeys], ); - const hasLoadedDiscoveredSshHosts = - desktopSshHosts.data !== null || desktopSshHosts.error !== null; - const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending; - const discoveredSshHostsError = sshConnectionError ?? desktopSshHosts.error; + const filteredDiscoveredSshHosts = useMemo( + () => filterDiscoveredSshHosts(unsavedDiscoveredSshHosts, savedBackendSshHost), + [savedBackendSshHost, unsavedDiscoveredSshHosts], + ); + const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending && desktopSshHosts.data === null; + const discoveredSshHostsError = desktopSshHosts.error; + const hasSshHostSuggestionContent = + desktopBridge !== undefined && + (isLoadingDiscoveredSshHosts || unsavedDiscoveredSshHosts.length > 0); const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null; const desktopAdvertisedEndpoints = desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS; @@ -2148,23 +2138,11 @@ export function ConnectionsSettings() { } }, []); - const handleAddSavedBackend = useCallback(async () => { - if (savedBackendMode === "ssh") { + // Shared by manual SSH submission and discovered-host selection. + const connectSavedBackendSshTarget = useCallback( + async (target: DesktopSshEnvironmentTarget) => { setIsAddingSavedBackend(true); setSavedBackendError(null); - let target: DesktopSshEnvironmentTarget; - try { - target = parseManualDesktopSshTarget({ - host: savedBackendSshHost, - username: savedBackendSshUsername, - port: savedBackendSshPort, - }); - } catch (error) { - setSavedBackendError(formatDesktopSshConnectionError(error)); - setIsAddingSavedBackend(false); - return; - } - const result = await connectSshEnvironment({ target, label: "" }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { @@ -2186,6 +2164,25 @@ export function ConnectionsSettings() { description: `${target.alias} is ready over an SSH-managed tunnel.`, }); setIsAddingSavedBackend(false); + }, + [connectSshEnvironment], + ); + + const handleAddSavedBackend = useCallback(async () => { + if (savedBackendMode === "ssh") { + let target: DesktopSshEnvironmentTarget; + try { + target = parseManualDesktopSshTarget({ + host: savedBackendSshHost, + username: savedBackendSshUsername, + port: savedBackendSshPort, + }); + } catch (error) { + setSavedBackendError(formatDesktopSshConnectionError(error)); + return; + } + + await connectSavedBackendSshTarget(target); return; } @@ -2243,7 +2240,7 @@ export function ConnectionsSettings() { setIsAddingSavedBackend(false); }, [ connectPairing, - connectSshEnvironment, + connectSavedBackendSshTarget, savedBackendHost, savedBackendMode, savedBackendPairingCode, @@ -2252,6 +2249,92 @@ export function ConnectionsSettings() { savedBackendSshUsername, ]); + const handleSavedBackendSshFieldKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && savedBackendSshHost.trim().length > 0) { + event.preventDefault(); + void handleAddSavedBackend(); + } + }, + [handleAddSavedBackend, savedBackendSshHost], + ); + + // Resolves a picked alias before connecting it through the manual SSH flow. + const handleSelectSshHostSuggestion = useCallback( + async (target: DesktopDiscoveredSshHost) => { + if (isAddingSavedBackend || !desktopBridge) return; + + setIsAddingSavedBackend(true); + setSavedBackendError(null); + setSavedBackendSshHost(target.alias); + let resolved: DesktopSshEnvironmentTarget; + try { + resolved = await desktopBridge.resolveSshHost(target.alias); + } catch (error) { + setSavedBackendError(formatDesktopSshConnectionError(error)); + setIsAddingSavedBackend(false); + return; + } + setSavedBackendSshUsername(resolved.username ?? ""); + setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port)); + await connectSavedBackendSshTarget(resolved); + }, + [connectSavedBackendSshTarget, desktopBridge, isAddingSavedBackend], + ); + + const handleSavedBackendSshHostKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + + // The popup only renders when there is content, so an "open" flag alone is not enough. + const isSshHostPopupVisible = sshHostSuggestionsOpen && hasSshHostSuggestionContent; + if (isSshHostPopupVisible) { + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false }, + }); + const index = threadJumpIndexFromCommand(command ?? ""); + const target = index === null ? undefined : filteredDiscoveredSshHosts[index]; + if (target) { + event.preventDefault(); + event.stopPropagation(); + setSshHostSuggestionsOpen(false); + void handleSelectSshHostSuggestion(target); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + + // A highlighted row means Enter belongs to the autocomplete, which selects it. + const hasHighlightedSshHost = + isSshHostPopupVisible && highlightedSshHostRef.current !== undefined; + if ( + !event.defaultPrevented && + !hasHighlightedSshHost && + event.key === "Enter" && + savedBackendSshHost.trim().length > 0 + ) { + event.preventDefault(); + void handleAddSavedBackend(); + } + }, + [ + filteredDiscoveredSshHosts, + handleAddSavedBackend, + handleSelectSshHostSuggestion, + hasSshHostSuggestionContent, + keybindings, + savedBackendSshHost, + sshHostSuggestionsOpen, + ], + ); + const handleConnectSavedBackend = useCallback( async (environmentId: EnvironmentId) => { setSavedBackendError(null); @@ -2294,46 +2377,6 @@ export function ConnectionsSettings() { [removeEnvironment], ); - const handleConnectSshHost = useCallback( - async (target: DesktopSshEnvironmentTarget, label?: string) => { - setConnectingSshHostAlias(target.alias); - if (savedBackendMode === "ssh") { - setSavedBackendError(null); - } else { - setSshConnectionError(null); - } - const result = await connectSshEnvironment({ - target, - ...(label === undefined ? {} : { label }), - }); - setConnectingSshHostAlias(null); - if (result._tag === "Success") { - setSavedBackendSshHost(""); - setSavedBackendSshUsername(""); - setSavedBackendSshPort(""); - setAddBackendDialogOpen(false); - toastManager.add({ - type: "success", - title: savedDesktopSshEnvironmentsByAlias[target.alias] - ? "Environment reconnected" - : "Environment connected", - description: `${label?.trim() || target.alias} is ready over an SSH-managed tunnel.`, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - const message = formatDesktopSshConnectionError(error); - if (savedBackendMode === "ssh") { - setSavedBackendError(message); - } else { - setSshConnectionError(message); - } - } - }, - [connectSshEnvironment, savedBackendMode, savedDesktopSshEnvironmentsByAlias], - ); - const visibleDesktopPairingLinks = desktopPairingLinks; const tailscaleHttpsEndpoint = useMemo( () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, @@ -2479,24 +2522,90 @@ export function ConnectionsSettings() { const renderSshFields = () => (
-
); const renderNetworkAccessToggle = () => ( @@ -3070,6 +3140,18 @@ export function ConnectionsSettings() { } /> ) : null} + {primaryEnvironmentId !== null ? ( + + } + /> + ) : null} {desktopBridge ? ( <> {renderNetworkAccessRow()} diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.test.ts b/apps/web/src/components/settings/EnvironmentIconPicker.test.ts new file mode 100644 index 000000000000..429b853ddaec --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentIconPicker.test.ts @@ -0,0 +1,41 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveEnvironmentIconPickerLock } from "./EnvironmentIconPicker"; + +const config = (environmentIcon: boolean | undefined) => + ({ + environment: { capabilities: environmentIcon === undefined ? {} : { environmentIcon } }, + }) as unknown as ServerConfig; + +describe("resolveEnvironmentIconPickerLock", () => { + it("locks until the environment is connected", () => { + expect( + resolveEnvironmentIconPickerLock({ serverConfig: null, operateAccess: "granted" }), + ).toMatch(/Connect/); + }); + + it("locks on servers that predate the setting, before looking at permissions", () => { + expect( + resolveEnvironmentIconPickerLock({ + serverConfig: config(undefined), + operateAccess: "denied", + }), + ).toMatch(/too old/); + }); + + it("locks when the session cannot operate the environment", () => { + expect( + resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "denied" }), + ).toMatch(/cannot change/); + }); + + it("stays open while access is still resolving so a slow session does not flicker", () => { + expect( + resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }), + ).toBeNull(); + expect( + resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx new file mode 100644 index 000000000000..f990aa5a2441 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -0,0 +1,161 @@ +import { + ENVIRONMENT_MACHINE_KINDS, + isEnvironmentMachineKind, + resolveEnvironmentMachineKind, + type EnvironmentId, + type ServerConfig, +} from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { isElectron } from "../../env"; +import { usePrimarySessionState } from "../../environments/primary"; +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironmentSessionState } from "../../state/session"; +import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, +} from "./ProviderSettingsPanel.logic"; + +const AUTOMATIC_VALUE = "automatic"; + +/** + * Why the picker is inert, in the order the user can do something about it. + * Null means it can be changed. + */ +export function resolveEnvironmentIconPickerLock(input: { + readonly serverConfig: ServerConfig | null; + readonly operateAccess: "granted" | "denied" | "pending"; +}): string | null { + if (input.serverConfig === null) { + return "Connect to this environment to change its icon."; + } + if (input.serverConfig.environment.capabilities.environmentIcon !== true) { + return "This environment's server is too old to keep an icon. Update it to choose one."; + } + if (input.operateAccess === "denied") { + return "Your session on this environment cannot change its settings."; + } + return null; +} + +// Same split the provider settings use: the desktop app owns its primary +// server outright, a browser session on the primary checks its cookie +// session's scopes, and a remote checks the scopes its own server reports. +function useEnvironmentOperateAccess(environmentId: EnvironmentId) { + const isPrimary = usePrimaryEnvironmentId() === environmentId; + const primarySession = usePrimarySessionState(); + const remoteSession = useEnvironmentSessionState(environmentId); + if (isPrimary) { + return isElectron + ? "granted" + : resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: primarySession.data, + isPending: primarySession.isPending, + hasError: primarySession.error !== null, + }); + } + return resolveRemoteOperateAccess({ + session: remoteSession.data, + isPending: remoteSession.isPending, + hasError: remoteSession.hasError, + }); +} + +/** + * Picks the machine glyph an environment wears everywhere it is listed. + * "Automatic" clears the override so the server's own detection shows + * through; the label says what that currently resolves to so the user can + * tell whether detection got it right before overriding. The control stays + * visible while locked so the current icon still reads, the same way + * server-scoped rows go inert instead of disappearing. + */ +export function EnvironmentIconPicker({ + environmentId, + serverConfig, + size = "sm", +}: { + readonly environmentId: EnvironmentId; + readonly serverConfig: ServerConfig | null; + readonly size?: "xs" | "sm"; +}) { + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const operateAccess = useEnvironmentOperateAccess(environmentId); + const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); + const override = serverConfig?.settings.environmentIcon ?? null; + const detected = serverConfig?.environment.platform.machine ?? null; + const resolved = resolveEnvironmentMachineKind(serverConfig); + const value = override ?? AUTOMATIC_VALUE; + const automaticLabel = + detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`; + + const handleValueChange = useCallback( + (next: string | null) => { + if (next === null) return; + if (next === AUTOMATIC_VALUE) { + updateSettings({ environmentIcon: null }); + } else if (isEnvironmentMachineKind(next)) { + updateSettings({ environmentIcon: next }); + } + }, + [updateSettings], + ); + + const select = ( + + ); + + if (lock === null) { + return select; + } + return ( + + + } + > + {select} + + + {lock} + + + ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 62a17b9368e6..803e694c061d 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -8,12 +8,14 @@ */ import { BROWSER_PROFILE_MAX_COUNT, + type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_BROWSER_LINK_TARGET, DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, @@ -465,6 +467,53 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo ); } +const LINK_TARGET_LABELS: Readonly> = { + system: "Your default browser", + app: "T3 Code", +}; + +function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) { + const linkTarget = useClientSettings((settings) => settings.browserLinkTarget); + const updateSettings = useUpdatePrimarySettings(); + + return ( + updateSettings({ browserLinkTarget: DEFAULT_BROWSER_LINK_TARGET })} + /> + ) : null + } + control={ + + } + /> + ); +} + function AgentBrowserAccessSetting() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -868,6 +917,7 @@ export function IntegrationsSettingsPanel() { + ); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx new file mode 100644 index 000000000000..9098b359d1ea --- /dev/null +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../ui/button", () => ({ + Button: ({ children }: { readonly children?: ReactNode }) => , +})); + +vi.mock("../ui/dialog", () => { + const Container = ({ children }: { readonly children?: ReactNode }) =>
{children}
; + return { + Dialog: Container, + DialogDescription: Container, + DialogFooter: Container, + DialogHeader: Container, + DialogPanel: Container, + DialogPopup: Container, + DialogTitle: Container, + }; +}); + +vi.mock("../ui/input", () => ({ Input: () => })); +vi.mock("../ui/scroll-area", () => ({ + ScrollArea: ({ children }: { readonly children?: ReactNode }) =>
{children}
, +})); +vi.mock("../ui/toggle-group", () => ({ + Toggle: ({ children, value }: { readonly children?: ReactNode; readonly value: string }) => ( + + ), + ToggleGroup: ({ + children, + value, + }: { + readonly children?: ReactNode; + readonly value: readonly string[]; + }) =>
{children}
, +})); + +import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; + +describe("ProjectIconPickerDialog", () => { + it("shows emoji first and selects it for an automatic project", () => { + const markup = renderToStaticMarkup( + {}} onSelect={() => {}} />, + ); + + expect(markup).toContain('data-current="emoji"'); + expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<")); + expect(markup).toContain("Or paste any emoji"); + }); +}); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx new file mode 100644 index 000000000000..4ecdb0f653c5 --- /dev/null +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -0,0 +1,205 @@ +import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import { DynamicIcon, type IconName } from "lucide-react/dynamic"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + filterProjectIconNames, + firstEmoji, + PROJECT_EMOJIS, + PROJECT_ICON_COLORS, + projectIconColorClassName, +} from "../../projectIconOptions"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { ScrollArea } from "../ui/scroll-area"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; + +const DEFAULT_ICON: IconName = "folder-code"; +const DEFAULT_COLOR: ProjectIconColor = "blue"; + +function iconLabel(name: string): string { + return name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function ProjectIconPickerDialog({ + current, + open, + onOpenChange, + onSelect, +}: { + readonly current: ProjectIconOverride | null; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onSelect: (icon: ProjectIconOverride) => void; +}) { + const [mode, setMode] = useState<"lucide" | "emoji">( + current?.kind === "lucide" ? "lucide" : "emoji", + ); + const [iconName, setIconName] = useState( + current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, + ); + const [color, setColor] = useState( + current?.kind === "lucide" ? current.color : DEFAULT_COLOR, + ); + const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); + const [query, setQuery] = useState(""); + const [customEmoji, setCustomEmoji] = useState(""); + const previousOpenRef = useRef(false); + + useEffect(() => { + if (open && !previousOpenRef.current) { + setMode(current?.kind === "lucide" ? "lucide" : "emoji"); + setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); + setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); + setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); + setQuery(""); + setCustomEmoji(""); + } + previousOpenRef.current = open; + }, [current, open]); + + const icons = useMemo(() => filterProjectIconNames(query), [query]); + const selectedColorClassName = projectIconColorClassName(color); + const save = () => { + onSelect( + mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji }, + ); + onOpenChange(false); + }; + + return ( + + + + Choose project icon + Pick an emoji, or choose any Lucide icon and color. + + + { + const value = next[0]; + if (value === "lucide" || value === "emoji") setMode(value); + }} + > + Emoji + Icons + + + {mode === "lucide" ? ( + <> +
+
Color
+
+ {PROJECT_ICON_COLORS.map((option) => ( + + ))} +
+
+ setQuery(event.currentTarget.value)} + /> + +
+ {icons.map((name) => ( + + ))} +
+
+ {icons.length === 0 ? ( +

No icons found.

+ ) : null} + + ) : ( + <> + +
+ {PROJECT_EMOJIS.map((option) => ( + + ))} +
+
+
+
+ Or paste any emoji +
+ { + const value = event.currentTarget.value; + setCustomEmoji(value); + const nextEmoji = firstEmoji(value); + if (nextEmoji) setEmoji(nextEmoji); + }} + /> +
+ + )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 913e529c4b42..891e219eb598 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -15,6 +15,7 @@ import { import type { ContextMenuItem, ModelSelection, + ProjectIconOverride, ProviderDriverKind, SidebarProjectGroupingMode, T3ProjectFileScript, @@ -27,6 +28,8 @@ import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; import { + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -118,6 +121,12 @@ import { } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; +const ProjectIconPickerDialog = lazy(() => + import("./ProjectIconPickerDialog").then((module) => ({ + default: module.ProjectIconPickerDialog, + })), +); + export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -335,6 +344,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); const faviconPath = representative.faviconPath ?? null; + const projectIcon = representative.projectIcon ?? null; const pickProjectFavicon = typeof window !== "undefined" && group.memberProjects.every( @@ -375,6 +385,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { defaultThreadEnvMode: ThreadEnvMode | null; autoPull: boolean; faviconPath: string | null; + projectIcon: ProjectIconOverride | null; }>, failureTitle: string, ): Promise> => { @@ -474,17 +485,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - // ----- favicon ----- + // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); + const [iconPickerOpen, setIconPickerOpen] = useState(false); const [isSavingFavicon, setIsSavingFavicon] = useState(false); const savingFaviconRef = useRef(false); - const setFaviconPath = useCallback( - async (faviconPath: string | null) => { + const setProjectIcon = useCallback( + async (input: { faviconPath: string | null; projectIcon: ProjectIconOverride | null }) => { if (savingFaviconRef.current) return; savingFaviconRef.current = true; setIsSavingFavicon(true); try { - await updateAllMembers({ faviconPath }, "Failed to update project icon"); + await updateAllMembers(input, "Failed to update project icon"); } finally { savingFaviconRef.current = false; setIsSavingFavicon(false); @@ -824,13 +836,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> void setFaviconPath(null)} + onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })} /> ) : null } @@ -839,9 +857,21 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { +