diff --git a/.context/upstream-sync.md b/.context/upstream-sync.md new file mode 100644 index 0000000000..dfaa031126 --- /dev/null +++ b/.context/upstream-sync.md @@ -0,0 +1,45 @@ +# Upstream Sync + +## Status atual + +- Data: 2026-04-10 +- Branch de trabalho: `t3code/upstream-sync-check` +- Upstream integrado nesta wave: `58e5f714b03ec44b42f00b52947a73d991fb8d8a` (`upstream/main`) +- Estado: merge aplicado, conflitos resolvidos e validacao local concluida; falta apenas commitar se quisermos fechar o merge no historico + +## Features locais vivas + +- `t3code-custom/file-references`: referencia de arquivos por path, colagem e envio +- `t3code-custom/chat/ThreadLoop*`: controles e comportamento de thread loop +- `t3code-custom/hooks/useComposerFileReferenceSend.ts`: serializacao custom no envio +- `t3code-custom/chat/useComposerSkillExtension.ts`: mapeia skills do Codex selecionadas no prompt para `{ name, path }` no send + +## Refatoracoes feitas para sair da frente do upstream + +- O composer agora usa o fluxo nativo do upstream para chips e busca de skills/slash commands +- Removido `apps/web/src/components/composerInlineTextNodes.ts`, que virou duplicacao da infraestrutura nova do upstream +- A logica custom de skill ficou reduzida ao que realmente e local: derivar `selectedSkills` para o envio do Codex +- `ChatComposer.tsx` voltou a depender de `selectedProviderStatus.skills` e `selectedProviderStatus.slashCommands`, em vez de puxar catalogo paralelo so para UI +- `ComposerPromptEditor.tsx` manteve o snapshot ampliado necessario para o paste custom de file references sem reabrir um fork inteiro do editor +- A placeholder custom do composer saiu de `ChatComposer.tsx` e voltou para `t3code-custom/chat/composerPlaceholder.ts` +- A orquestracao custom de envio do composer foi empurrada para `t3code-custom/hooks/useComposerSendExtension.ts`, reduzindo regra local espalhada em `ChatView.tsx` +- `ComposerPromptEditor.tsx` parou de persistir estado extra de selecao no snapshot interno; a leitura ampliada agora acontece so quando precisa + +## Hotspots que continuam sensiveis + +- `apps/web/src/components/chat/ChatComposer.tsx` + Continua sendo o ponto de encaixe entre UX do core e extensoes locais do composer +- `apps/web/src/components/ComposerPromptEditor.tsx` + Qualquer mudanca de snapshot, cursor ou selection mexe direto com paste custom e chips inline +- `apps/web/src/components/ChatView.tsx` + Ainda concentra ligacao entre envio, timeline e hooks custom, mas menos regra local ficou espalhada ali +- `apps/web/src/composerDraftStore.ts` + Permanece hotspot compartilhado para draft, imagens, terminal context e file references +- `apps/web/src/components/chat/MessagesTimeline.tsx` + Continua sendo fronteira entre renderizacao do core e parser dos sentinelas custom + +## Regra pratica para o proximo sync + +- Se a mudanca for UX de skill/slash command, tentar absorver do upstream primeiro +- Se a mudanca for regra de negocio local, empurrar para `t3code-custom/*` +- Se precisar tocar `ChatComposer` ou `ComposerPromptEditor`, fazer o minimo e deixar a adaptacao visivel diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dfa3bde2f8..a38ffd2df1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.15", + "version": "0.0.17", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index 57a066eb58..8f586deb70 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => { ]); }); + it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { + const canListenOnHost = vi.fn(async (port: number, host: string) => { + if (port === 3773 && host === "127.0.0.1") return true; + if (port === 3773 && host === "0.0.0.0") return false; + return port === 3774; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0"], + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3774); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3774, "127.0.0.1"], + [3774, "0.0.0.0"], + ]); + }); + + it("checks overlapping hosts sequentially to avoid self-interference", async () => { + let inFlightCount = 0; + const canListenOnHost = vi.fn(async (_port: number, _host: string) => { + inFlightCount += 1; + const overlapped = inFlightCount > 1; + await Promise.resolve(); + inFlightCount -= 1; + return !overlapped; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0", "::"], + startPort: 3773, + maxPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3773); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3773, "::"], + ]); + }); + it("fails when the scan range is exhausted", async () => { const canListenOnHost = vi.fn(async () => false); @@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => { maxPort: 65535, canListenOnHost, }), - ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535"); + ).rejects.toThrow( + "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", + ); expect(canListenOnHost.mock.calls).toEqual([ [65534, "127.0.0.1"], diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts index e70272c397..1ce90a257f 100644 --- a/apps/desktop/src/backendPort.ts +++ b/apps/desktop/src/backendPort.ts @@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions { readonly host: string; readonly startPort?: number; readonly maxPort?: number; + readonly requiredHosts?: ReadonlyArray; readonly canListenOnHost?: (port: number, host: string) => Promise; } @@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; +const normalizeHosts = ( + host: string, + requiredHosts: ReadonlyArray, +): ReadonlyArray => + Array.from( + new Set( + [host, ...requiredHosts] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0), + ), + ); + +async function canListenOnAllHosts( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Promise, +): Promise { + for (const candidateHost of hosts) { + if (!(await canListenOnHost(port, candidateHost))) { + return false; + } + } + + return true; +} + export async function resolveDesktopBackendPort({ host, startPort = DEFAULT_DESKTOP_BACKEND_PORT, maxPort = MAX_TCP_PORT, + requiredHosts = [], canListenOnHost = defaultCanListenOnHost, }: ResolveDesktopBackendPortOptions): Promise { if (!isValidPort(startPort)) { @@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({ throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); } + const hostsToCheck = normalizeHosts(host, requiredHosts); + // Keep desktop startup predictable across app restarts by probing upward from // the same preferred port instead of picking a fresh ephemeral port. for (let port = startPort; port <= maxPort; port += 1) { - if (await canListenOnHost(port, host)) { + if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { return port; } } throw new Error( - `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`, + `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, ); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 310b973458..be2aac0850 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1695,6 +1695,14 @@ function createWindow(): BrowserWindow { ); } + if (params.mediaType === "image") { + menuTemplate.push({ + label: "Copy Image", + click: () => window.webContents.copyImageAt(params.x, params.y), + }); + menuTemplate.push({ type: "separator" }); + } + menuTemplate.push( { role: "cut", enabled: params.editFlags.canCut }, { role: "copy", enabled: params.editFlags.canCopy }, @@ -1773,6 +1781,7 @@ async function bootstrap(): Promise { (await resolveDesktopBackendPort({ host: DESKTOP_LOOPBACK_HOST, startPort: DEFAULT_DESKTOP_BACKEND_PORT, + requiredHosts: desktopSettings.serverExposureMode === "network-accessible" ? ["0.0.0.0"] : [], })); writeDesktopLogHeader( configuredBackendPort === undefined diff --git a/apps/server/package.json b/apps/server/package.json index e59c7c208c..950079a4dc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.15", + "version": "0.0.17", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 21bc515aa7..299da67fab 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -14,6 +14,22 @@ import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog import rootPackageJson from "../../../package.json" with { type: "json" }; import serverPackageJson from "../package.json" with { type: "json" }; +interface PackageJson { + name: string; + repository: { + type: string; + url: string; + directory: string; + }; + bin: Record; + type: string; + version: string; + engines: Record; + files: string[]; + dependencies: Record; + overrides: Record; +} + class CliError extends Data.TaggedError("CliError")<{ readonly message: string; readonly cause?: unknown; @@ -177,7 +193,7 @@ const publishCmd = Command.make( const backupPath = `${packageJsonPath}.bak`; // Assert build assets exist - for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) { + for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { return yield* new CliError({ @@ -192,7 +208,7 @@ const publishCmd = Command.make( // Resolve catalog dependencies before any file mutations. If this throws, // acquire fails and no release hook runs, so filesystem must still be untouched. const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); - const pkg = { + const pkg: PackageJson = { name: serverPackageJson.name, repository: serverPackageJson.repository, bin: serverPackageJson.bin, @@ -200,7 +216,8 @@ const publishCmd = Command.make( version, engines: serverPackageJson.engines, files: serverPackageJson.files, - dependencies: serverPackageJson.dependencies as Record, + dependencies: serverPackageJson.dependencies, + overrides: rootPackageJson.overrides, }; pkg.dependencies = resolveCatalogDependencies( @@ -208,6 +225,11 @@ const publishCmd = Command.make( rootPackageJson.workspaces.catalog, "apps/server dependencies", ); + pkg.overrides = resolveCatalogDependencies( + pkg.overrides, + rootPackageJson.workspaces.catalog, + "root overrides", + ); const original = yield* fs.readFileString(packageJsonPath); yield* fs.writeFileString(backupPath, original); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index fa7191110a..cb1c6fa4c4 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -20,7 +20,10 @@ import { AuthError, type ServerAuthShape, } from "../Services/ServerAuth.ts"; -import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { + SessionCredentialError, + SessionCredentialService, +} from "../Services/SessionCredentialService.ts"; import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; type BootstrapExchangeResult = { @@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () { const authenticateToken = (token: string): Effect.Effect => sessions.verify(token).pipe( + Effect.tapError((cause: SessionCredentialError) => + Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: cause.message, + }), + ), + ), Effect.map((session) => ({ sessionId: session.sessionId, subject: session.subject, diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts index 640cc030f8..13ca0233ee 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + expect(descriptor.sessionCookieName).toBe("t3_session_3773"); }).pipe( Effect.provide( makeServerAuthPolicyLayer({ mode: "desktop", + port: 3773, }), ), ), @@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.sessionCookieName).toBe("t3_session"); }).pipe( Effect.provide( makeServerAuthPolicyLayer({ diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index 9f952cc9ec..43735b4761 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; -import { SESSION_COOKIE_NAME } from "../utils.ts"; +import { resolveSessionCookieName } from "../utils.ts"; import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; export const makeServerAuthPolicy = Effect.gen(function* () { @@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () { policy, bootstrapMethods, sessionMethods: ["browser-session-cookie", "bearer-session-token"], - sessionCookieName: SESSION_COOKIE_NAME, + sessionCookieName: resolveSessionCookieName({ + mode: config.mode, + port: config.port, + }), }; return { diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index a106d15fd5..8fd6dba8a4 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -1,6 +1,6 @@ import * as Crypto from "node:crypto"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Predicate } from "effect"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; @@ -28,17 +28,23 @@ export const makeServerSecretStore = Effect.gen(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const isMissingSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => - cause instanceof PlatformError.PlatformError && cause.reason._tag === "NotFound"; + const isPlatformError = (u: unknown): u is PlatformError.PlatformError => + Predicate.isTagged(u, "PlatformError"); - const isAlreadyExistsSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => - cause instanceof PlatformError.PlatformError && cause.reason._tag === "AlreadyExists"; + // t3code note: we added this guard after an upstream sync regression where + // the error handler read `cause.reason._tag` directly. Keep this helper when + // updating ServerSecretStore from upstream or non-Platform errors will throw + // inside the fallback path and bypass SecretStoreError wrapping. + const hasPlatformReasonTag = ( + cause: unknown, + tag: PlatformError.PlatformError["reason"]["_tag"], + ): cause is PlatformError.PlatformError => isPlatformError(cause) && cause.reason._tag === tag; const get: ServerSecretStoreShape["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( Effect.map((bytes) => Uint8Array.from(bytes)), Effect.catch((cause) => - isMissingSecretFileError(cause) + hasPlatformReasonTag(cause, "NotFound") ? Effect.succeed(null) : Effect.fail( new SecretStoreError({ @@ -108,7 +114,7 @@ export const makeServerSecretStore = Effect.gen(function* () { return create(name, generated).pipe( Effect.as(Uint8Array.from(generated)), Effect.catchTag("SecretStoreError", (error) => - isAlreadyExistsSecretFileError(error.cause) + isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" ? get(name).pipe( Effect.flatMap((created) => created !== null @@ -129,7 +135,7 @@ export const makeServerSecretStore = Effect.gen(function* () { const remove: ServerSecretStoreShape["remove"] = (name) => fileSystem.remove(resolveSecretPath(name)).pipe( Effect.catch((cause) => - isMissingSecretFileError(cause) + hasPlatformReasonTag(cause, "NotFound") ? Effect.void : Effect.fail( new SecretStoreError({ diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 7fc8178a08..5ff4bbffff 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from " import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; import { Option } from "effect"; +import { ServerConfig } from "../../config.ts"; import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; -import { SESSION_COOKIE_NAME } from "../utils.ts"; import { SessionCredentialError, SessionCredentialService, @@ -17,6 +17,7 @@ import { import { base64UrlDecodeUtf8, base64UrlEncode, + resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, } from "../utils.ts"; @@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit): AuthCli } export const makeSessionCredentialService = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; const secretStore = yield* ServerSecretStore; const authSessions = yield* AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); + const cookieName = resolveSessionCookieName({ + mode: serverConfig.mode, + port: serverConfig.port, + }); const toSessionCredentialError = (message: string) => (cause: unknown) => new SessionCredentialError({ @@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); return { - cookieName: SESSION_COOKIE_NAME, + cookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index e87c66c6b9..2c76a81f65 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/ import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as Crypto from "node:crypto"; -export const SESSION_COOKIE_NAME = "t3_session"; +const SESSION_COOKIE_NAME = "t3_session"; + +export function resolveSessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly port: number; +}): string { + if (input.mode !== "desktop") { + return SESSION_COOKIE_NAME; + } + + return `${SESSION_COOKIE_NAME}_${input.port}`; +} export function base64UrlEncode(input: string | Uint8Array): string { const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index e5547377b4..62f8405aea 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -2001,7 +2001,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { "GitCore.removeWorktree", input.cwd, args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, error, ), ), diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 280679e337..ce56e91d53 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Schema } from "effect"; +import { Effect, Layer, Schema, SchemaIssue } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; import { runProcess } from "../../processRunner"; @@ -154,7 +154,7 @@ function decodeGitHubJson( (error) => new GitHubCliError({ operation, - detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, cause: error, }), ), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9feec28637..d00be86d3e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,12 +4,16 @@ import type { ServerProvider, ServerProviderModel, ServerProviderAuth, + ServerProviderSlashCommand, ServerProviderState, } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; +import { + query as claudeQuery, + type SlashCommand as ClaudeSlashCommand, +} from "@anthropic-ai/claude-agent-sdk"; import { buildServerProvider, @@ -276,18 +280,6 @@ function extractClaudeAuthMethodFromOutput(result: CommandResult): string | unde return Option.getOrUndefined(findAuthMethod(parsed.success)); } -// ── Dynamic model capability adjustment ───────────────────────────── - -/** Subscription types where the 1M context window is included in the plan. */ -const PREMIUM_SUBSCRIPTION_TYPES = new Set([ - "max", - "maxplan", - "max5", - "max20", - "enterprise", - "team", -]); - function toTitleCaseWords(value: string): string { return value .split(/[\s_-]+/g) @@ -348,44 +340,77 @@ function claudeAuthMetadata(input: { return undefined; } -/** - * Adjust the built-in model list based on the user's detected subscription. - * - * - Premium tiers (Max, Enterprise, Team): 1M context becomes the default. - * - Other tiers (Pro, free, unknown): 200k context stays the default; - * 1M remains available as a manual option so users can still enable it. - */ -export function adjustModelsForSubscription( - baseModels: ReadonlyArray, - subscriptionType: string | undefined, -): ReadonlyArray { - const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); - if (!normalized || !PREMIUM_SUBSCRIPTION_TYPES.has(normalized)) { - return baseModels; - } +// ── SDK capability probe ──────────────────────────────────────────── - // Flip 1M to be the default for premium users - return baseModels.map((model) => { - const caps = model.capabilities; - if (!caps || caps.contextWindowOptions.length === 0) return model; +const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; - return { - ...model, - capabilities: { - ...caps, - contextWindowOptions: caps.contextWindowOptions.map((opt) => - opt.value === "1m" - ? { value: opt.value, label: opt.label, isDefault: true as const } - : { value: opt.value, label: opt.label }, - ), - }, - }; - }); +function nonEmptyProbeString(value: string): string | undefined { + const candidate = value.trim(); + return candidate ? candidate : undefined; } -// ── SDK capability probe ──────────────────────────────────────────── +function parseClaudeInitializationCommands( + commands: ReadonlyArray | undefined, +): ReadonlyArray { + return dedupeSlashCommands( + (commands ?? []).flatMap((command) => { + const name = nonEmptyProbeString(command.name); + if (!name) { + return []; + } + + const description = nonEmptyProbeString(command.description); + const argumentHint = nonEmptyProbeString(command.argumentHint); + + return [ + { + name, + ...(description ? { description } : {}), + ...(argumentHint ? { input: { hint: argumentHint } } : {}), + } satisfies ServerProviderSlashCommand, + ]; + }), + ); +} -const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +function dedupeSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const commandsByName = new Map(); + + for (const command of commands) { + const name = nonEmptyProbeString(command.name); + if (!name) { + continue; + } + + const key = name.toLowerCase(); + const existing = commandsByName.get(key); + if (!existing) { + commandsByName.set(key, { + ...command, + name, + }); + continue; + } + + commandsByName.set(key, { + ...existing, + ...(existing.description + ? {} + : command.description + ? { description: command.description } + : {}), + ...(existing.input?.hint + ? {} + : command.input?.hint + ? { input: { hint: command.input.hint } } + : {}), + }); + } + + return [...commandsByName.values()]; +} /** * Probe account information by spawning a lightweight Claude Agent SDK @@ -408,13 +433,16 @@ const probeClaudeCapabilities = (binaryPath: string) => { pathToClaudeCodeExecutable: binaryPath, abortController: abort, maxTurns: 0, - settingSources: [], + settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, }, }); const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; + return { + subscriptionType: init.account?.subscriptionType, + slashCommands: parseClaudeInitializationCommands(init.commands), + }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -443,6 +471,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( resolveSubscriptionType?: (binaryPath: string) => Effect.Effect, + resolveSlashCommands?: ( + binaryPath: string, + ) => Effect.Effect | undefined>, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -538,6 +569,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const slashCommands = + (resolveSlashCommands + ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( + Effect.orElseSucceed(() => undefined), + ) + : undefined) ?? []; + const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + // ── Auth check + subscription detection ──────────────────────────── const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( @@ -563,8 +602,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); } - const resolvedModels = adjustModelsForSubscription(models, subscriptionType); - // ── Handle auth results (same logic as before, adjusted models) ── if (Result.isFailure(authProbe)) { @@ -573,7 +610,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -592,7 +630,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -609,7 +648,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -632,12 +672,18 @@ export const ClaudeProviderLive = Layer.effect( const subscriptionProbeCache = yield* Cache.make({ capacity: 1, timeToLive: Duration.minutes(5), - lookup: (binaryPath: string) => - probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)), + lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), }); - const checkProvider = checkClaudeProviderStatus((binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath), + const checkProvider = checkClaudeProviderStatus( + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.subscriptionType), + ), + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.slashCommands), + ), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 01108ea73f..ddb5107eaa 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -238,14 +238,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { .pipe(Effect.result); assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } - assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { - return; - } assert.equal(result.failure.provider, "codex"); assert.equal(result.failure.threadId, "sess-missing"); assert.equal(result.failure.cause instanceof Error, true); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 039e059276..a41cb496ae 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -51,18 +51,11 @@ export interface CodexAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - function toSessionError( threadId: ThreadId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { - const normalized = toMessage(cause, "").toLowerCase(); + const normalized = cause instanceof Error ? cause.message.toLowerCase() : ""; if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, @@ -88,7 +81,7 @@ function toRequestError(threadId: ThreadId, method: string, cause: unknown): Pro return new ProviderAdapterRequestError({ provider: PROVIDER, method, - detail: toMessage(cause, `${method} failed`), + detail: cause instanceof Error ? `${method} failed: ${cause.message}` : `${method} failed`, cause, }); } @@ -1427,7 +1420,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: toMessage(cause, "Failed to start Codex adapter session."), + detail: `Failed to start Codex adapter session: ${cause instanceof Error ? cause.message : String(cause)}.`, cause, }), }); @@ -1455,7 +1448,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( new ProviderAdapterRequestError({ provider: PROVIDER, method: "turn/start", - detail: toMessage(cause, "Failed to read attachment file."), + detail: `Failed to read attachment file: ${cause.message}.`, cause, }), ), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3509fa9257..421621c969 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -5,6 +5,7 @@ import type { ServerProvider, ServerProviderModel, ServerProviderAuth, + ServerProviderSkill, ServerProviderState, } from "@t3tools/contracts"; import { @@ -44,7 +45,7 @@ import { codexAuthSubType, type CodexAccountSnapshot, } from "../codexAccount"; -import { probeCodexAccount } from "../codexAppServer"; +import { probeCodexDiscovery } from "../codexAppServer"; import { CodexProvider } from "../Services/CodexProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; @@ -304,8 +305,9 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeCodexCapabilities = (input: { readonly binaryPath: string; readonly homePath?: string; + readonly cwd: string; }) => - Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe( + Effect.tryPromise((signal) => probeCodexDiscovery({ ...input, signal })).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -334,6 +336,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly binaryPath: string; readonly homePath?: string; }) => Effect.Effect, + resolveSkills?: (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + }) => Effect.Effect | undefined>, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -389,7 +396,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Codex CLI (`codex`) is not installed or not on PATH." - : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : `Failed to execute Codex CLI health check: ${error.message}.`, }, }); } @@ -449,12 +456,22 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } + const skills = + (resolveSkills + ? yield* resolveSkills({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + cwd: process.cwd(), + }).pipe(Effect.orElseSucceed(() => undefined)) + : undefined) ?? []; + if (yield* hasCustomModelProvider) { return buildServerProvider({ provider: PROVIDER, enabled: codexSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version: parsedVersion, @@ -484,15 +501,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, status: "warning", auth: { status: "unknown" }, - message: - error instanceof Error - ? `Could not verify Codex authentication status: ${error.message}.` - : "Could not verify Codex authentication status.", + message: `Could not verify Codex authentication status: ${error.message}.`, }, }); } @@ -503,6 +518,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -521,6 +537,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -546,16 +563,29 @@ export const CodexProviderLive = Layer.effect( capacity: 4, timeToLive: Duration.minutes(5), lookup: (key: string) => { - const [binaryPath, homePath] = JSON.parse(key) as [string, string | undefined]; + const [binaryPath, homePath, cwd] = JSON.parse(key) as [string, string | undefined, string]; return probeCodexCapabilities({ binaryPath, + cwd, ...(homePath ? { homePath } : {}), }); }, }); - const checkProvider = checkCodexProviderStatus((input) => - Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])), + const getDiscovery = (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + }) => + Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath, input.cwd])); + + const checkProvider = checkCodexProviderStatus( + (input) => + getDiscovery({ + ...input, + cwd: process.cwd(), + }).pipe(Effect.map((discovery) => discovery?.account)), + (input) => getDiscovery(input).pipe(Effect.map((discovery) => discovery?.skills)), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3d6f418603..9c12048e45 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -221,6 +221,49 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes probed codex skills in the provider snapshot", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus( + () => + Effect.succeed({ + type: "chatgpt" as const, + planType: "pro" as const, + sparkEnabled: true, + }), + () => + Effect.succeed([ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]), + ); + + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides spark from codex models for unsupported chatgpt plans", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -499,6 +542,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], + slashCommands: [], + skills: [], }, { provider: "claudeAgent", @@ -509,6 +554,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], + slashCommands: [], + skills: [], }, ] as const satisfies ReadonlyArray; @@ -887,6 +934,85 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes probed claude slash commands in the provider snapshot", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + () => + Effect.succeed([ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("deduplicates probed claude slash commands by name", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + () => + Effect.succeed([ + { + name: "ui", + description: "Explore and refine UI", + }, + { + name: "ui", + input: { hint: "component-or-screen" }, + }, + ]), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "ui", + description: "Explore and refine UI", + input: { hint: "component-or-screen" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index d25fc3533e..7b3c9eeb79 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; +import type { ServerProviderSkill } from "@t3tools/contracts"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; interface JsonRpcProbeResponse { @@ -10,10 +11,74 @@ interface JsonRpcProbeResponse { }; } +export interface CodexDiscoverySnapshot { + readonly account: CodexAccountSnapshot; + readonly skills: ReadonlyArray; +} + function readErrorMessage(response: JsonRpcProbeResponse): string | undefined { return typeof response.error?.message === "string" ? response.error.message : undefined; } +function readObject(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function readArray(value: unknown): ReadonlyArray | undefined { + return Array.isArray(value) ? value : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nonEmptyTrimmed(value: unknown): string | undefined { + const candidate = readString(value)?.trim(); + return candidate ? candidate : undefined; +} + +function parseCodexSkillsResult(result: unknown, cwd: string): ReadonlyArray { + const resultRecord = readObject(result); + const dataBuckets = readArray(resultRecord?.data) ?? []; + const matchingBucket = dataBuckets.find( + (value) => nonEmptyTrimmed(readObject(value)?.cwd) === cwd, + ); + const rawSkills = + readArray(readObject(matchingBucket)?.skills) ?? readArray(resultRecord?.skills) ?? []; + + return rawSkills.flatMap((value) => { + const skill = readObject(value); + const display = readObject(skill?.interface); + const name = nonEmptyTrimmed(skill?.name); + const path = nonEmptyTrimmed(skill?.path); + if (!name || !path) { + return []; + } + + return [ + { + name, + path, + enabled: skill?.enabled !== false, + ...(nonEmptyTrimmed(skill?.description) + ? { description: nonEmptyTrimmed(skill?.description) } + : {}), + ...(nonEmptyTrimmed(skill?.scope) ? { scope: nonEmptyTrimmed(skill?.scope) } : {}), + ...(nonEmptyTrimmed(display?.displayName) + ? { displayName: nonEmptyTrimmed(display?.displayName) } + : {}), + ...(nonEmptyTrimmed(skill?.shortDescription) || nonEmptyTrimmed(display?.shortDescription) + ? { + shortDescription: + nonEmptyTrimmed(skill?.shortDescription) ?? + nonEmptyTrimmed(display?.shortDescription), + } + : {}), + } satisfies ServerProviderSkill, + ]; + }); +} + export function buildCodexInitializeParams() { return { clientInfo: { @@ -40,11 +105,12 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo child.kill(); } -export async function probeCodexAccount(input: { +export async function probeCodexDiscovery(input: { readonly binaryPath: string; readonly homePath?: string; + readonly cwd: string; readonly signal?: AbortSignal; -}): Promise { +}): Promise { return await new Promise((resolve, reject) => { const child = spawn(input.binaryPath, ["app-server"], { env: { @@ -57,6 +123,8 @@ export async function probeCodexAccount(input: { const output = readline.createInterface({ input: child.stdout }); let completed = false; + let account: CodexAccountSnapshot | undefined; + let skills: ReadonlyArray | undefined; const cleanup = () => { output.removeAllListeners(); @@ -79,15 +147,25 @@ export async function probeCodexAccount(input: { reject( error instanceof Error ? error - : new Error(`Codex account probe failed: ${String(error)}.`), + : new Error(`Codex discovery probe failed: ${String(error)}.`), ), ); + const maybeResolve = () => { + if (account && skills !== undefined) { + const resolvedAccount = account; + const resolvedSkills = skills; + finish(() => resolve({ account: resolvedAccount, skills: resolvedSkills })); + } + }; + if (input.signal?.aborted) { - fail(new Error("Codex account probe aborted.")); + fail(new Error("Codex discovery probe aborted.")); return; } - input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted."))); + input.signal?.addEventListener("abort", () => + fail(new Error("Codex discovery probe aborted.")), + ); const writeMessage = (message: unknown) => { if (!child.stdin.writable) { @@ -103,7 +181,7 @@ export async function probeCodexAccount(input: { try { parsed = JSON.parse(line); } catch { - fail(new Error("Received invalid JSON from codex app-server during account probe.")); + fail(new Error("Received invalid JSON from codex app-server during discovery probe.")); return; } @@ -120,18 +198,27 @@ export async function probeCodexAccount(input: { } writeMessage({ method: "initialized" }); - writeMessage({ id: 2, method: "account/read", params: {} }); + writeMessage({ id: 2, method: "skills/list", params: { cwds: [input.cwd] } }); + writeMessage({ id: 3, method: "account/read", params: {} }); return; } if (response.id === 2) { + const errorMessage = readErrorMessage(response); + skills = errorMessage ? [] : parseCodexSkillsResult(response.result, input.cwd); + maybeResolve(); + return; + } + + if (response.id === 3) { const errorMessage = readErrorMessage(response); if (errorMessage) { fail(new Error(`account/read failed: ${errorMessage}`)); return; } - finish(() => resolve(readCodexAccountSnapshot(response.result))); + account = readCodexAccountSnapshot(response.result); + maybeResolve(); } }); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4c80d78e20..40246563ae 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -2,6 +2,8 @@ import type { ModelCapabilities, ServerProvider, ServerProviderAuth, + ServerProviderSkill, + ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; @@ -32,8 +34,7 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -export function isCommandMissingCause(error: unknown): boolean { - if (!(error instanceof Error)) return false; +export function isCommandMissingCause(error: Error): boolean { const lower = error.message.toLowerCase(); return lower.includes("enoent") || lower.includes("notfound"); } @@ -131,6 +132,8 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + slashCommands?: ReadonlyArray; + skills?: ReadonlyArray; probe: ProviderProbeResult; }): ServerProvider { return { @@ -143,6 +146,8 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, + slashCommands: [...(input.slashCommands ?? [])], + skills: [...(input.skills ?? [])], }; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 408b91fadc..e15ead0484 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "browser-session-cookie", "bearer-session-token", ]); - assert.equal(body.auth.sessionCookieName, "t3_session"); + assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index c6a16b0b49..4bdeba68e1 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -8,7 +8,6 @@ import { } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { - Data, Effect, Encoding, Equal, @@ -17,6 +16,7 @@ import { FileSystem, Layer, Option, + Schema, Scope, Semaphore, SynchronizedRef, @@ -54,22 +54,28 @@ const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); -type TerminalSubprocessChecker = ( - terminalPid: number, -) => Effect.Effect; - -class TerminalSubprocessCheckError extends Data.TaggedError("TerminalSubprocessCheckError")<{ - readonly message: string; - readonly cause?: unknown; - readonly terminalPid: number; - readonly command: "powershell" | "pgrep" | "ps"; -}> {} +class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( + "TerminalSubprocessCheckError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + terminalPid: Schema.Number, + command: Schema.Literals(["powershell", "pgrep", "ps"]), + }, +) {} + +class TerminalProcessSignalError extends Schema.TaggedErrorClass()( + "TerminalProcessSignalError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + signal: Schema.Literals(["SIGTERM", "SIGKILL"]), + }, +) {} -class TerminalProcessSignalError extends Data.TaggedError("TerminalProcessSignalError")<{ - readonly message: string; - readonly cause?: unknown; - readonly signal: "SIGTERM" | "SIGKILL"; -}> {} +interface TerminalSubprocessChecker { + (terminalPid: number): Effect.Effect; +} interface ShellCandidate { shell: string; @@ -271,9 +277,8 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { if (current instanceof Error) { messages.push(current.message); - const cause = (current as { cause?: unknown }).cause; - if (cause) { - queue.push(cause); + if (current.cause) { + queue.push(current.cause); } continue; } @@ -876,7 +881,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.logWarning("failed to persist terminal history", { threadId, terminalId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ); @@ -959,7 +964,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.catch((cleanupError) => Effect.logWarning("failed to remove legacy terminal history", { threadId, - error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + error: cleanupError, }), ), ); @@ -975,7 +980,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.logWarning("failed to delete terminal history", { threadId, terminalId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ); @@ -985,7 +990,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.logWarning("failed to delete terminal history", { threadId, terminalId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ); @@ -1011,7 +1016,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.catch((error) => Effect.logWarning("failed to delete terminal histories for thread", { threadId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ), @@ -1463,12 +1468,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const terminalPid = session.pid; const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe( Effect.map(Option.some), - Effect.catch((error) => + Effect.catch((reason) => Effect.logWarning("failed to check terminal subprocess activity", { threadId: session.threadId, terminalId: session.terminalId, terminalPid, - error: error instanceof Error ? error.message : String(error), + reason, }).pipe(Effect.as(Option.none())), ), ); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69bf1..09f6905ce9 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -129,6 +129,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); + it.effect("prioritizes exact basename matches ahead of broader path matches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" }); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "docs/composer.tsx-notes.md"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 }); + + expect(result.entries[0]?.path).toBe("src/components/Composer.tsx"); + }), + ); + it.effect("tracks truncation without sorting every fuzzy match", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 542e187457..c4d3c3c81f 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -4,6 +4,12 @@ import type { Dirent } from "node:fs"; import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; import { type ProjectEntry } from "@t3tools/contracts"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, + type RankedSearchResult, +} from "@t3tools/shared/searchRanking"; import { GitCore } from "../../git/Services/GitCore.ts"; import { @@ -40,10 +46,7 @@ interface SearchableWorkspaceEntry extends ProjectEntry { normalizedName: string; } -interface RankedWorkspaceEntry { - entry: SearchableWorkspaceEntry; - score: number; -} +type RankedWorkspaceEntry = RankedSearchResult; function toPosixPath(input: string): string { return input.replaceAll("\\", "/"); @@ -74,45 +77,6 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt }; } -function normalizeQuery(input: string): string { - return input - .trim() - .replace(/^[@./]+/, "") - .toLowerCase(); -} - -function scoreSubsequenceMatch(value: string, query: string): number | null { - if (!query) return 0; - - let queryIndex = 0; - let firstMatchIndex = -1; - let previousMatchIndex = -1; - let gapPenalty = 0; - - for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { - if (value[valueIndex] !== query[queryIndex]) { - continue; - } - - if (firstMatchIndex === -1) { - firstMatchIndex = valueIndex; - } - if (previousMatchIndex !== -1) { - gapPenalty += valueIndex - previousMatchIndex - 1; - } - - previousMatchIndex = valueIndex; - queryIndex += 1; - if (queryIndex === query.length) { - const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; - const lengthPenalty = Math.min(64, value.length - query.length); - return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; - } - } - - return null; -} - function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { if (!query) { return entry.kind === "directory" ? 0 : 1; @@ -120,81 +84,32 @@ function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | nu const { normalizedPath, normalizedName } = entry; - if (normalizedName === query) return 0; - if (normalizedPath === query) return 1; - if (normalizedName.startsWith(query)) return 2; - if (normalizedPath.startsWith(query)) return 3; - if (normalizedPath.includes(`/${query}`)) return 4; - if (normalizedName.includes(query)) return 5; - if (normalizedPath.includes(query)) return 6; - - const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); - if (nameFuzzyScore !== null) { - return 100 + nameFuzzyScore; - } - - const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); - if (pathFuzzyScore !== null) { - return 200 + pathFuzzyScore; + const scores = [ + scoreQueryMatch({ + value: normalizedName, + query, + exactBase: 0, + prefixBase: 2, + includesBase: 5, + fuzzyBase: 100, + }), + scoreQueryMatch({ + value: normalizedPath, + query, + exactBase: 1, + prefixBase: 3, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 200, + boundaryMarkers: ["/"], + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; } - return null; -} - -function compareRankedWorkspaceEntries( - left: RankedWorkspaceEntry, - right: RankedWorkspaceEntry, -): number { - const scoreDelta = left.score - right.score; - if (scoreDelta !== 0) return scoreDelta; - return left.entry.path.localeCompare(right.entry.path); -} - -function findInsertionIndex( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, -): number { - let low = 0; - let high = rankedEntries.length; - - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - const current = rankedEntries[middle]; - if (!current) { - break; - } - - if (compareRankedWorkspaceEntries(candidate, current) < 0) { - high = middle; - } else { - low = middle + 1; - } - } - - return low; -} - -function insertRankedEntry( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, - limit: number, -): void { - if (limit <= 0) { - return; - } - - const insertionIndex = findInsertionIndex(rankedEntries, candidate); - if (rankedEntries.length < limit) { - rankedEntries.splice(insertionIndex, 0, candidate); - return; - } - - if (insertionIndex >= limit) { - return; - } - - rankedEntries.splice(insertionIndex, 0, candidate); - rankedEntries.pop(); + return Math.min(...scores); } function isPathInIgnoredDirectory(relativePath: string): boolean { @@ -214,9 +129,6 @@ function directoryAncestorsOf(relativePath: string): string[] { return directories; } -const processErrorDetail = (cause: unknown): string => - cause instanceof Error ? cause.message : String(cause); - export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; const gitOption = yield* Effect.serviceOption(GitCore); @@ -319,7 +231,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { new WorkspaceEntriesError({ cwd, operation: "workspaceEntries.readDirectoryEntries", - detail: processErrorDetail(cause), + detail: cause instanceof Error ? cause.message : String(cause), cause, }), }).pipe( @@ -472,7 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( Effect.map((index) => { - const normalizedQuery = normalizeQuery(input.query); + const normalizedQuery = normalizeSearchQuery(input.query, { + trimLeadingPattern: /^[@./]+/, + }); const limit = Math.max(0, Math.floor(input.limit)); const rankedEntries: RankedWorkspaceEntry[] = []; let matchedEntryCount = 0; @@ -484,11 +398,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () { } matchedEntryCount += 1; - insertRankedEntry(rankedEntries, { entry, score }, limit); + insertRankedSearchResult( + rankedEntries, + { item: entry, score, tieBreaker: entry.path }, + limit, + ); } return { - entries: rankedEntries.map((candidate) => candidate.entry), + entries: rankedEntries.map((candidate) => candidate.item), truncated: index.truncated || matchedEntryCount > limit, }; }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 11c2340946..7f45a08939 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -316,10 +316,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => worktreePath: input.worktreePath, scriptId: input.scriptId, terminalId: input.terminalId, - detail: - error instanceof Error - ? error.message - : "Unknown setup activity dispatch failure.", + detail: error.message, }, ), ), diff --git a/apps/web/package.json b/apps/web/package.json index d127743705..a447b3e0ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.15", + "version": "0.0.17", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 8a8f9bd6ab..e91266d65f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -44,7 +44,9 @@ export const BranchToolbar = memo(function BranchToolbar({ ); const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); const serverThread = useStore(serverThreadSelector); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const draftThread = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), + ); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 32c80f6542..76f64d93fe 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -91,7 +91,9 @@ export function BranchToolbarBranchSelector({ const serverThread = useStore(serverThreadSelector); const serverSession = serverThread?.session ?? null; const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const draftThread = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 2261684dd9..f763632873 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -88,37 +88,76 @@ interface ViewportSpec { attachmentTolerancePx: number; } +function browserTextTolerance(base: number, linux: number): number { + // t3code note: browser text wrapping differs a lot between macOS and the + // Linux GitHub runner, so these parity assertions need platform-aware + // tolerances or CI turns into a font-metrics coin flip after upstream syncs. + return /Linux/i.test(globalThis.navigator?.userAgent ?? "") ? linux : base; +} + +const WIDE_FOOTER_OVERFLOW_TEST_WIDTH_PX = 720; + const DEFAULT_VIEWPORT: ViewportSpec = { name: "desktop", width: 960, height: 1_100, - textTolerancePx: 44, + textTolerancePx: browserTextTolerance(44, 180), attachmentTolerancePx: 56, }; const WIDE_FOOTER_VIEWPORT: ViewportSpec = { name: "wide-footer", width: 1_400, height: 1_100, - textTolerancePx: 44, + textTolerancePx: browserTextTolerance(44, 180), attachmentTolerancePx: 56, }; const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { name: "compact-footer", width: 430, height: 932, - textTolerancePx: 56, + textTolerancePx: browserTextTolerance(56, 280), attachmentTolerancePx: 56, }; const TEXT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, - { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { + name: "tablet", + width: 720, + height: 1_024, + textTolerancePx: browserTextTolerance(44, 180), + attachmentTolerancePx: 56, + }, + { + name: "mobile", + width: 430, + height: 932, + textTolerancePx: browserTextTolerance(56, 280), + attachmentTolerancePx: 56, + }, + { + name: "narrow", + width: 320, + height: 700, + textTolerancePx: browserTextTolerance(84, 450), + attachmentTolerancePx: 56, + }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, + { + name: "mobile", + width: 430, + height: 932, + textTolerancePx: browserTextTolerance(56, 280), + attachmentTolerancePx: 120, + }, + { + name: "narrow", + width: 320, + height: 700, + textTolerancePx: browserTextTolerance(84, 450), + attachmentTolerancePx: 120, + }, ] as const satisfies readonly ViewportSpec[]; interface UserRowMeasurement { @@ -169,6 +208,8 @@ function createBaseServerConfig(): ServerConfig { auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], + slashCommands: [], + skills: [], }, ], availableEditors: [], @@ -717,17 +758,30 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { }; } -function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { +function createSnapshotWithPlanFollowUpPrompt(options?: { + modelSelection?: { provider: "codex"; model: string }; + planMarkdown?: string; +}): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-follow-up-target" as MessageId, targetText: "plan follow-up thread", }); + const modelSelection = options?.modelSelection ?? { + provider: "codex" as const, + model: "gpt-5", + }; + const planMarkdown = + options?.planMarkdown ?? "# Follow-up plan\n\n- Keep the composer footer stable on resize."; return { ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID ? { ...project, defaultModelSelection: modelSelection } : project, + ), threads: snapshot.threads.map((thread) => thread.id === THREAD_ID ? Object.assign({}, thread, { + modelSelection, interactionMode: "plan", latestTurn: { turnId: "turn-plan-follow-up" as TurnId, @@ -741,7 +795,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { { id: "plan-follow-up-browser-test", turnId: "turn-plan-follow-up" as TurnId, - planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.", + planMarkdown, implementedAt: null, implementationThreadId: null, createdAt: isoAt(1_002), @@ -1327,6 +1381,7 @@ async function mountChatView(options: { snapshot: OrchestrationReadModel; configureFixture?: (fixture: TestFixture) => void; resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; + initialPath?: string; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); @@ -1346,7 +1401,7 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], + initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), ); @@ -2512,6 +2567,119 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("uses the active draft route session when changing the base branch", async () => { + const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); + const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); + + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [staleDraftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + [activeDraftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, + [PROJECT_DRAFT_KEY]: activeDraftId, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${activeDraftId}`, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + hasOriginRemote: true, + nextCursor: null, + totalCount: 2, + branches: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + { + name: "release/next", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + }; + } + return undefined; + }, + }); + + try { + const branchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "From main", + ) as HTMLButtonElement | null, + 'Unable to find branch selector button with "From main".', + ); + branchButton.click(); + + const branchOption = await waitForElement( + () => + Array.from(document.querySelectorAll("span")).find( + (element) => element.textContent?.trim() === "release/next", + ) as HTMLSpanElement | null, + 'Unable to find the "release/next" branch option.', + ); + branchOption.click(); + + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( + "release/next", + ); + expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( + "main", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.trim().includes("From release/next"), + ); + expect(updatedButton).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -3606,8 +3774,9 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const initialModelPickerOffset = initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; + const initialImplementButton = await waitForButtonByText("Implement"); + const initialImplementWidth = initialImplementButton.getBoundingClientRect().width; - await waitForButtonByText("Implement"); await waitForElement( () => document.querySelector('button[aria-label="Implementation actions"]'), @@ -3639,6 +3808,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); + expect(Math.abs(implementRect.width - initialImplementWidth)).toBeLessThanOrEqual(1); expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( 1, ); @@ -3650,6 +3820,60 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the wide desktop follow-up layout expanded when the footer still fits", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPlanFollowUpPrompt({ + modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + planMarkdown: + "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", + }), + }); + + try { + await waitForButtonByText("Implement"); + + await vi.waitFor( + () => { + const footer = document.querySelector('[data-chat-composer-footer="true"]'); + const actions = document.querySelector( + '[data-chat-composer-actions="right"]', + ); + + expect(footer?.dataset.chatComposerFooterCompact).toBe("false"); + expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("false"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the follow-up footer actions contained after a narrow desktop resize", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPlanFollowUpPrompt({ + modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + planMarkdown: + "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", + }), + }); + + try { + await waitForButtonByText("Implement"); + + await mounted.setContainerSize({ + width: WIDE_FOOTER_OVERFLOW_TEST_WIDTH_PX, + height: WIDE_FOOTER_VIEWPORT.height, + }); + + await expectComposerActionsContained(); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the slash-command menu visible above the composer", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -3689,4 +3913,55 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("shows a tooltip with the skill description when hovering a skill pill", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-skill-tooltip-target" as MessageId, + targetText: "skill tooltip thread", + }), + configureFixture: (nextFixture) => { + const provider = nextFixture.serverConfig.providers[0]; + if (!provider) { + throw new Error("Expected default provider in test fixture."); + } + ( + provider as { + skills: ServerConfig["providers"][number]["skills"]; + } + ).skills = [ + { + name: "agent-browser", + displayName: "Agent Browser", + description: "Open pages, click around, and inspect web apps.", + path: "/Users/test/.agents/skills/agent-browser/SKILL.md", + enabled: true, + }, + ]; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "use the $agent-browser "); + await waitForComposerText("use the $agent-browser "); + + await waitForElement( + () => document.querySelector('[data-composer-skill-chip="true"]'), + "Unable to find rendered composer skill chip.", + ); + await page.getByText("Agent Browser").hover(); + + await vi.waitFor( + () => { + const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); + expect(tooltip).not.toBeNull(); + expect(tooltip?.textContent).toContain("Open pages, click around, and inspect web apps."); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3290920bb8..cdf64f4833 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -118,7 +118,6 @@ import { type DraftId, } from "../composerDraftStore"; import { - appendTerminalContextsToPrompt, formatTerminalContextLabel, type TerminalContextDraft, type TerminalContextSelection, @@ -156,7 +155,7 @@ import { waitForStartedServerThread, } from "./ChatView.logic"; import type { ComposerFileReference } from "../t3code-custom/file-references"; -import { useComposerFileReferenceSend } from "../t3code-custom/hooks"; +import { useComposerSendExtension } from "../t3code-custom/hooks"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useServerAvailableEditors, @@ -170,6 +169,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type ThreadPlanCatalogEntry = Pick; @@ -572,6 +572,7 @@ export default function ChatView(props: ChatViewProps) { () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); + const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useStore( @@ -582,10 +583,17 @@ export default function ChatView(props: ChatViewProps) { ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const setThreadChangedFilesExpanded = useUiStateStore( + (store) => store.setThreadChangedFilesExpanded, + ); const activeThreadLastVisitedAt = useUiStateStore((store) => + routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + ); + const changedFilesExpandedByTurnId = useUiStateStore((store) => routeKind === "server" - ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))] - : undefined, + ? (store.threadChangedFilesExpandedById[routeThreadKey] ?? + EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID) + : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( @@ -981,6 +989,16 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + const handleSetChangedFilesExpanded = useCallback( + (turnId: TurnId, expanded: boolean) => { + if (routeKind !== "server") { + return; + } + setThreadChangedFilesExpanded(routeThreadKey, turnId, expanded); + }, + [routeKind, routeThreadKey, setThreadChangedFilesExpanded], + ); + useEffect(() => { if (!serverThread?.id) return; if (!latestTurnSettled) return; @@ -1403,7 +1421,7 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - const composerFileReferenceSend = useComposerFileReferenceSend({ + const composerSendExtension = useComposerSendExtension({ composerDraftTarget, workspaceRoot: activeWorkspaceRoot, promptRef, @@ -2459,8 +2477,9 @@ export default function ChatView(props: ChatViewProps) { selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; - if (isResolvingFileReferences) { - setThreadError(activeThread.id, "Espere as referências de arquivo terminarem de resolver."); + const blockedSendError = composerSendExtension.getBlockedSendError(isResolvingFileReferences); + if (blockedSendError) { + setThreadError(activeThread.id, blockedSendError); return; } const promptForSend = promptRef.current; @@ -2475,7 +2494,7 @@ export default function ChatView(props: ChatViewProps) { fileReferenceCount: composerFileReferences.length, terminalContexts: composerTerminalContexts, }); - const trimmedWithFileReferences = composerFileReferenceSend.appendPromptWithFileReferences( + const trimmedWithFileReferences = composerSendExtension.buildPlanFollowUpText( trimmed, composerFileReferences, ); @@ -2543,14 +2562,11 @@ export default function ChatView(props: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const composerFileReferencesSnapshot = [...composerFileReferences]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const promptWithFileReferences = composerFileReferenceSend.appendPromptWithFileReferences( - promptForSend, - composerFileReferencesSnapshot, - ); - const messageTextForSend = appendTerminalContextsToPrompt( - promptWithFileReferences, - composerTerminalContextsSnapshot, - ); + const messageTextForSend = composerSendExtension.buildMessageTextForSend({ + prompt: promptForSend, + fileReferences: composerFileReferencesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + }); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -2617,7 +2633,7 @@ export default function ChatView(props: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - const titleSeed = composerFileReferenceSend.deriveTitleSeed({ + const titleSeed = composerSendExtension.deriveTitleSeed({ trimmedPrompt: trimmed, firstImageName: firstComposerImageName, fileReferences: composerFileReferencesSnapshot, @@ -2712,7 +2728,7 @@ export default function ChatView(props: ChatViewProps) { })().catch(async (err: unknown) => { if ( !turnStartSucceeded && - composerFileReferenceSend.restoreDraftAfterSendFailure({ + composerSendExtension.restoreDraftAfterSendFailure({ promptForSend, composerImagesSnapshot, composerFileReferencesSnapshot, @@ -3294,6 +3310,7 @@ export default function ChatView(props: ChatViewProps) { (SURROUND_SYMBOLS); const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; +type SerializedComposerMentionNode = Spread< + { + path: string; + type: "composer-mention"; + version: 1; + }, + SerializedTextNode +>; + +type SerializedComposerSkillNode = Spread< + { + skillName: string; + skillLabel?: string; + skillDescription?: string; + type: "composer-skill"; + version: 1; + }, + SerializedLexicalNode +>; + type SerializedComposerTerminalContextNode = Spread< { context: TerminalContextDraft; @@ -106,6 +132,227 @@ const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: () => {}, }); +class ComposerMentionNode extends TextNode { + __path: string; + + static override getType(): string { + return "composer-mention"; + } + + static override clone(node: ComposerMentionNode): ComposerMentionNode { + return new ComposerMentionNode(node.__path, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { + return $createComposerMentionNode(serializedNode.path); + } + + constructor(path: string, key?: NodeKey) { + const normalizedPath = path.startsWith("@") ? path.slice(1) : path; + super(`@${normalizedPath}`, key); + this.__path = normalizedPath; + } + + override exportJSON(): SerializedComposerMentionNode { + return { + ...super.exportJSON(), + path: this.__path, + type: "composer-mention", + version: 1, + }; + } + + override createDOM(_config: EditorConfig): HTMLElement { + const dom = document.createElement("span"); + dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + renderMentionChipDom(dom, this.__path); + return dom; + } + + override updateDOM( + prevNode: ComposerMentionNode, + dom: HTMLElement, + _config: EditorConfig, + ): boolean { + dom.contentEditable = "false"; + if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { + renderMentionChipDom(dom, this.__path); + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerMentionNode(path: string): ComposerMentionNode { + return $applyNodeReplacement(new ComposerMentionNode(path)); +} + +const SKILL_CHIP_ICON_SVG = ``; + +function resolveSkillDescription( + skill: Pick, +): string | null { + const shortDescription = skill.shortDescription?.trim(); + if (shortDescription) { + return shortDescription; + } + const description = skill.description?.trim(); + return description || null; +} + +type ComposerSkillMetadata = { + label: string; + description: string | null; +}; + +function skillMetadataByName( + skills: ReadonlyArray, +): ReadonlyMap { + return new Map( + skills.map((skill) => [ + skill.name, + { + label: formatProviderSkillDisplayName(skill), + description: resolveSkillDescription(skill), + }, + ]), + ); +} + +function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) { + const chip = ( + + + ); + + if (!props.skillDescription) { + return chip; + } + + return ( + + + + {props.skillDescription} + + + ); +} + +class ComposerSkillNode extends DecoratorNode { + __skillName: string; + __skillLabel: string; + __skillDescription: string | null; + + static override getType(): string { + return "composer-skill"; + } + + static override clone(node: ComposerSkillNode): ComposerSkillNode { + return new ComposerSkillNode( + node.__skillName, + node.__skillLabel, + node.__skillDescription, + node.__key, + ); + } + + static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { + return $createComposerSkillNode( + serializedNode.skillName, + serializedNode.skillLabel ?? serializedNode.skillName, + serializedNode.skillDescription ?? null, + ).updateFromJSON(serializedNode); + } + + constructor( + skillName: string, + skillLabel: string, + skillDescription: string | null, + key?: NodeKey, + ) { + super(key); + const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; + this.__skillName = normalizedSkillName; + this.__skillLabel = skillLabel; + this.__skillDescription = skillDescription; + } + + override exportJSON(): SerializedComposerSkillNode { + return { + ...super.exportJSON(), + skillName: this.__skillName, + skillLabel: this.__skillLabel, + ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), + type: "composer-skill", + version: 1, + }; + } + + override createDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.className = "inline-flex align-middle leading-none"; + return dom; + } + + override updateDOM(): false { + return false; + } + + override getTextContent(): string { + return `$${this.__skillName}`; + } + + override isInline(): true { + return true; + } + + override decorate(): ReactElement { + return ( + + ); + } +} + +function $createComposerSkillNode( + skillName: string, + skillLabel: string, + skillDescription: string | null, +): ComposerSkillNode { + return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); +} + function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -172,11 +419,39 @@ function $createComposerTerminalContextNode( type ComposerInlineTokenNode = | ComposerMentionNode - | ComposerCustomTokenNode + | ComposerSkillNode | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { - return isComposerInlineTextNode(candidate) || candidate instanceof ComposerTerminalContextNode; + return ( + candidate instanceof ComposerMentionNode || + candidate instanceof ComposerSkillNode || + candidate instanceof ComposerTerminalContextNode + ); +} + +function resolvedThemeFromDocument(): "light" | "dark" { + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +function renderMentionChipDom(container: HTMLElement, pathValue: string): void { + container.textContent = ""; + container.style.setProperty("user-select", "none"); + container.style.setProperty("-webkit-user-select", "none"); + + const theme = resolvedThemeFromDocument(); + const icon = document.createElement("img"); + icon.alt = ""; + icon.ariaHidden = "true"; + icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; + icon.loading = "lazy"; + icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); + + const label = document.createElement("span"); + label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; + label.textContent = basenameOfPath(pathValue); + + container.append(icon, label); } function terminalContextSignature(contexts: ReadonlyArray): string { @@ -196,8 +471,20 @@ function terminalContextSignature(contexts: ReadonlyArray) .join("\u001e"); } -function customTokenTextsSignature(customTokenTexts: ReadonlyArray): string { - return customTokenTexts.join("\u001e"); +function skillSignature(skills: ReadonlyArray): string { + return skills + .map((skill) => + [ + skill.name, + skill.displayName ?? "", + skill.shortDescription ?? "", + skill.description ?? "", + skill.path, + skill.scope ?? "", + skill.enabled ? "1" : "0", + ].join("\u001f"), + ) + .join("\u001e"); } function clampExpandedCursor(value: string, cursor: number): number { @@ -308,12 +595,12 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode || node instanceof ComposerCustomTokenNode) { + if (node instanceof ComposerMentionNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -355,12 +642,12 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode || node instanceof ComposerCustomTokenNode) { + if (node instanceof ComposerMentionNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -386,10 +673,7 @@ function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { - if (node instanceof ComposerMentionNode) { - return findSelectionPointForInlineToken(node, remainingRef); - } - if (node instanceof ComposerCustomTokenNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { return findSelectionPointForInlineToken(node, remainingRef); } if (node instanceof ComposerTerminalContextNode) { @@ -531,26 +815,6 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { return Math.max(0, Math.min(offset, composerLength)); } -function $readSelectionOffsetsFromEditorState(fallback: { start: number; end: number }): { - start: number; - end: number; -} { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return fallback; - } - const anchorOffset = getAbsoluteOffsetForPoint( - selection.anchor.getNode(), - selection.anchor.offset, - ); - const focusOffset = getAbsoluteOffsetForPoint(selection.focus.getNode(), selection.focus.offset); - const composerLength = $getComposerRootLength(); - return { - start: Math.max(0, Math.min(Math.min(anchorOffset, focusOffset), composerLength)), - end: Math.max(0, Math.min(Math.max(anchorOffset, focusOffset), composerLength)), - }; -} - function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { @@ -562,29 +826,6 @@ function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { return Math.max(0, Math.min(offset, expandedLength)); } -function $readExpandedSelectionOffsetsFromEditorState(fallback: { start: number; end: number }): { - start: number; - end: number; -} { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return fallback; - } - const anchorOffset = getExpandedAbsoluteOffsetForPoint( - selection.anchor.getNode(), - selection.anchor.offset, - ); - const focusOffset = getExpandedAbsoluteOffsetForPoint( - selection.focus.getNode(), - selection.focus.offset, - ); - const expandedLength = $getRoot().getTextContent().length; - return { - start: Math.max(0, Math.min(Math.min(anchorOffset, focusOffset), expandedLength)), - end: Math.max(0, Math.min(Math.max(anchorOffset, focusOffset), expandedLength)), - }; -} - function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { @@ -601,23 +842,28 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, - customTokenTexts: ReadonlyArray = [], + skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt, terminalContexts, { - customTokenTexts, - }); + const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path)); continue; } - if (segment.type === "custom-token") { - paragraph.append($createComposerCustomTokenNode(segment.tokenText)); + if (segment.type === "skill") { + const metadata = skillMetadata.get(segment.name); + paragraph.append( + $createComposerSkillNode( + segment.name, + metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), + metadata?.description ?? null, + ), + ); continue; } if (segment.type === "terminal-context") { @@ -660,7 +906,7 @@ interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; - customTokenTexts?: ReadonlyArray; + skills: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; @@ -740,7 +986,7 @@ function ComposerCommandKeyPlugin(props: { return null; } -function ComposerInlineTokenArrowPlugin(props: { customTokenTexts: ReadonlyArray }) { +function ComposerInlineTokenArrowPlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { @@ -754,11 +1000,7 @@ function ComposerInlineTokenArrowPlugin(props: { customTokenTexts: ReadonlyArray const currentOffset = $readSelectionOffsetFromEditorState(0); if (currentOffset <= 0) return; const promptValue = $getRoot().getTextContent(); - if ( - !isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left", { - customTokenTexts: props.customTokenTexts, - }) - ) { + if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { return; } nextOffset = currentOffset - 1; @@ -785,11 +1027,7 @@ function ComposerInlineTokenArrowPlugin(props: { customTokenTexts: ReadonlyArray const composerLength = $getComposerRootLength(); if (currentOffset >= composerLength) return; const promptValue = $getRoot().getTextContent(); - if ( - !isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right", { - customTokenTexts: props.customTokenTexts, - }) - ) { + if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { return; } nextOffset = currentOffset + 1; @@ -809,7 +1047,7 @@ function ComposerInlineTokenArrowPlugin(props: { customTokenTexts: ReadonlyArray unregisterLeft(); unregisterRight(); }; - }, [editor, props.customTokenTexts]); + }, [editor]); return null; } @@ -911,11 +1149,11 @@ function ComposerInlineTokenBackspacePlugin() { function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; - customTokenTexts: ReadonlyArray; + skills: ReadonlyArray; }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); - const customTokenTextsRef = useRef(props.customTokenTexts); + const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ value: string; expandedStart: number; @@ -932,8 +1170,8 @@ function ComposerSurroundSelectionPlugin(props: { }, [props.terminalContexts]); useEffect(() => { - customTokenTextsRef.current = props.customTokenTexts; - }, [props.customTokenTexts]); + skillMetadataRef.current = skillMetadataByName(props.skills); + }, [props.skills]); const applySurroundInsertion = useCallback( (inputData: string): boolean => { @@ -961,11 +1199,7 @@ function ComposerSurroundSelectionPlugin(props: { return null; } const value = $getRoot().getTextContent(); - if ( - selectionTouchesMentionBoundary(value, range.start, range.end, { - customTokenTexts: customTokenTextsRef.current, - }) - ) { + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { return null; } return { @@ -984,15 +1218,10 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt( - nextValue, - terminalContextsRef.current, - customTokenTextsRef.current, - ); + $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, - { customTokenTexts: customTokenTextsRef.current }, ); $setSelectionRangeAtComposerOffsets( selectionStart + inputData.length, @@ -1041,11 +1270,7 @@ function ComposerSurroundSelectionPlugin(props: { return; } const value = $getRoot().getTextContent(); - if ( - selectionTouchesMentionBoundary(value, range.start, range.end, { - customTokenTexts: customTokenTextsRef.current, - }) - ) { + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; @@ -1129,7 +1354,6 @@ function ComposerSurroundSelectionPlugin(props: { const replacementStart = collapseExpandedComposerCursor( currentValue, pendingDeadKeySelection.expandedStart, - { customTokenTexts: customTokenTextsRef.current }, ); $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); const replacementSelection = $getSelection(); @@ -1196,7 +1420,7 @@ function ComposerPromptEditorInner({ value, cursor, terminalContexts, - customTokenTexts = [], + skills, disabled, placeholder, className, @@ -1209,23 +1433,16 @@ function ComposerPromptEditorInner({ }: ComposerPromptEditorInnerProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); - const customTokenTextsRef = useRef(customTokenTexts); - const initialCursor = clampCollapsedComposerCursor(value, cursor, { customTokenTexts }); + const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); - const currentCustomTokenTextsSignature = customTokenTextsSignature(customTokenTexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); - const customTokenTextsSignatureRef = useRef(currentCustomTokenTextsSignature); - const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor, { - customTokenTexts, - }); + const skillsSignature = skillSignature(skills); + const skillsSignatureRef = useRef(skillsSignature); + const skillMetadataRef = useRef(skillMetadataByName(skills)); const snapshotRef = useRef({ value, cursor: initialCursor, - expandedCursor: initialExpandedCursor, - selectionStart: initialCursor, - selectionEnd: initialCursor, - expandedSelectionStart: initialExpandedCursor, - expandedSelectionEnd: initialExpandedCursor, + expandedCursor: expandCollapsedComposerCursor(value, initialCursor), terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); @@ -1238,25 +1455,24 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); - useEffect(() => { - customTokenTextsRef.current = customTokenTexts; - }, [customTokenTexts]); + useLayoutEffect(() => { + skillMetadataRef.current = skillMetadataByName(skills); + }, [skills]); useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); useLayoutEffect(() => { - const normalizedCursor = clampCollapsedComposerCursor(value, cursor, { customTokenTexts }); + const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; - const customTokensChanged = - customTokenTextsSignatureRef.current !== currentCustomTokenTextsSignature; + const skillsChanged = skillsSignatureRef.current !== skillsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && !contextsChanged && - !customTokensChanged + !skillsChanged ) { return; } @@ -1264,39 +1480,24 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value, cursor: normalizedCursor, - expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor, { - customTokenTexts, - }), - selectionStart: normalizedCursor, - selectionEnd: normalizedCursor, - expandedSelectionStart: expandCollapsedComposerCursor(value, normalizedCursor, { - customTokenTexts, - }), - expandedSelectionEnd: expandCollapsedComposerCursor(value, normalizedCursor, { - customTokenTexts, - }), + expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), terminalContextIds: terminalContexts.map((context) => context.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; - customTokenTextsSignatureRef.current = currentCustomTokenTextsSignature; + skillsSignatureRef.current = skillsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if ( - previousSnapshot.value === value && - !contextsChanged && - !customTokensChanged && - !isFocused - ) { + if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || customTokensChanged; + previousSnapshot.value !== value || contextsChanged || skillsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, customTokenTexts); + $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1305,23 +1506,13 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [ - currentCustomTokenTextsSignature, - customTokenTexts, - cursor, - editor, - terminalContexts, - terminalContextsSignature, - value, - ]); + }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor, { - customTokenTexts: customTokenTextsRef.current, - }); + const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); @@ -1329,21 +1520,7 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor, { - customTokenTexts: customTokenTextsRef.current, - }), - selectionStart: boundedCursor, - selectionEnd: boundedCursor, - expandedSelectionStart: expandCollapsedComposerCursor( - snapshotRef.current.value, - boundedCursor, - { customTokenTexts: customTokenTextsRef.current }, - ), - expandedSelectionEnd: expandCollapsedComposerCursor( - snapshotRef.current.value, - boundedCursor, - { customTokenTexts: customTokenTextsRef.current }, - ), + expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, }; onChangeRef.current( @@ -1367,16 +1544,28 @@ function ComposerPromptEditorInner({ expandedSelectionEnd: number; terminalContextIds: string[]; } => { - let snapshot = snapshotRef.current; + let snapshot: { + value: string; + cursor: number; + expandedCursor: number; + selectionStart: number; + selectionEnd: number; + expandedSelectionStart: number; + expandedSelectionEnd: number; + terminalContextIds: string[]; + } = { + ...snapshotRef.current, + selectionStart: snapshotRef.current.cursor, + selectionEnd: snapshotRef.current.cursor, + expandedSelectionStart: snapshotRef.current.expandedCursor, + expandedSelectionEnd: snapshotRef.current.expandedCursor, + }; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor, { - customTokenTexts: customTokenTextsRef.current, - }); + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), - { customTokenTexts: customTokenTextsRef.current }, ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, @@ -1386,31 +1575,27 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); - const selectionOffsets = $readSelectionOffsetsFromEditorState({ - start: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionStart, { - customTokenTexts: customTokenTextsRef.current, - }), - end: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionEnd, { - customTokenTexts: customTokenTextsRef.current, - }), - }); - const expandedSelectionOffsets = $readExpandedSelectionOffsetsFromEditorState({ - start: clampExpandedCursor(nextValue, snapshotRef.current.expandedSelectionStart), - end: clampExpandedCursor(nextValue, snapshotRef.current.expandedSelectionEnd), - }); + const expandedSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); + const expandedSelectionStart = expandedSelectionRange?.start ?? nextExpandedCursor; + const expandedSelectionEnd = expandedSelectionRange?.end ?? nextExpandedCursor; const terminalContextIds = collectTerminalContextIds($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, - selectionStart: selectionOffsets.start, - selectionEnd: selectionOffsets.end, - expandedSelectionStart: expandedSelectionOffsets.start, - expandedSelectionEnd: expandedSelectionOffsets.end, + selectionStart: collapseExpandedComposerCursor(nextValue, expandedSelectionStart), + selectionEnd: collapseExpandedComposerCursor(nextValue, expandedSelectionEnd), + expandedSelectionStart, + expandedSelectionEnd, terminalContextIds, }; }); - snapshotRef.current = snapshot; + snapshotRef.current = { + value: snapshot.value, + cursor: snapshot.cursor, + expandedCursor: snapshot.expandedCursor, + terminalContextIds: snapshot.terminalContextIds, + }; return snapshot; }, [editor]); @@ -1426,7 +1611,6 @@ function ComposerPromptEditorInner({ collapseExpandedComposerCursor( snapshotRef.current.value, snapshotRef.current.value.length, - { customTokenTexts: customTokenTextsRef.current }, ), ); }, @@ -1438,13 +1622,10 @@ function ComposerPromptEditorInner({ const handleEditorChange = useCallback((editorState: EditorState) => { editorState.read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor, { - customTokenTexts: customTokenTextsRef.current, - }); + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), - { customTokenTexts: customTokenTextsRef.current }, ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, @@ -1454,28 +1635,12 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); - const selectionOffsets = $readSelectionOffsetsFromEditorState({ - start: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionStart, { - customTokenTexts: customTokenTextsRef.current, - }), - end: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionEnd, { - customTokenTexts: customTokenTextsRef.current, - }), - }); - const expandedSelectionOffsets = $readExpandedSelectionOffsetsFromEditorState({ - start: clampExpandedCursor(nextValue, snapshotRef.current.expandedSelectionStart), - end: clampExpandedCursor(nextValue, snapshotRef.current.expandedSelectionEnd), - }); const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.selectionStart === selectionOffsets.start && - previousSnapshot.selectionEnd === selectionOffsets.end && - previousSnapshot.expandedSelectionStart === expandedSelectionOffsets.start && - previousSnapshot.expandedSelectionEnd === expandedSelectionOffsets.end && previousSnapshot.terminalContextIds.length === terminalContextIds.length && previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) ) { @@ -1488,19 +1653,11 @@ function ComposerPromptEditorInner({ value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, - selectionStart: selectionOffsets.start, - selectionEnd: selectionOffsets.end, - expandedSelectionStart: expandedSelectionOffsets.start, - expandedSelectionEnd: expandedSelectionOffsets.end, terminalContextIds, }; const cursorAdjacentToMention = - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left", { - customTokenTexts: customTokenTextsRef.current, - }) || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right", { - customTokenTexts: customTokenTextsRef.current, - }); + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); onChangeRef.current( nextValue, nextCursor, @@ -1524,7 +1681,7 @@ function ComposerPromptEditorInner({ data-testid="composer-editor" aria-placeholder={placeholder} placeholder={} - onPasteCapture={onPasteCapture} + {...(onPasteCapture ? { onPasteCapture } : {})} onPaste={onPaste} /> } @@ -1539,11 +1696,8 @@ function ComposerPromptEditorInner({ /> - - + + @@ -1560,7 +1714,7 @@ export const ComposerPromptEditor = forwardRef< value, cursor, terminalContexts, - customTokenTexts = [], + skills, disabled, placeholder, className, @@ -1574,17 +1728,17 @@ export const ComposerPromptEditor = forwardRef< ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); - const initialCustomTokenTextsRef = useRef(customTokenTexts); + const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerCustomTokenNode, ComposerTerminalContextNode], + nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], editorState: () => { $setComposerEditorPrompt( initialValueRef.current, initialTerminalContextsRef.current, - initialCustomTokenTextsRef.current, + initialSkillMetadataRef.current, ); }, onError: (error) => { @@ -1600,7 +1754,7 @@ export const ComposerPromptEditor = forwardRef< value={value} cursor={cursor} terminalContexts={terminalContexts} - customTokenTexts={customTokenTexts} + skills={skills} disabled={disabled} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index c54e63f54f..651b383fa0 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -426,4 +426,37 @@ describe("GitActionsControl thread-scoped progress toast", () => { host.remove(); } }); + + it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { + hasServerThreadRef.current = false; + activeDraftThreadRef.current = { + threadId: SHARED_THREAD_ID, + environmentId: ENVIRONMENT_A, + branch: "feature/base-branch", + worktreePath: null, + envMode: "worktree", + }; + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { + container: host, + }, + ); + + try { + await Promise.resolve(); + + expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); + expect(setThreadBranchSpy).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + host.remove(); + } + }); }); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d8ff73da78..6d2312e4aa 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -49,7 +49,7 @@ import { import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; -import { useComposerDraftStore } from "~/composerDraftStore"; +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { useStore } from "~/store"; @@ -58,6 +58,7 @@ import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; + draftId?: DraftId; } interface PendingDefaultBranchAction { @@ -209,7 +210,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadRef, + draftId, +}: GitActionsControlProps) { const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), @@ -221,7 +226,11 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction ); const activeServerThread = useStore(activeServerThreadSelector); const activeDraftThread = useComposerDraftStore((store) => - activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null, + draftId + ? store.getDraftSession(draftId) + : activeThreadRef + ? store.getDraftThreadByRef(activeThreadRef) + : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); @@ -282,7 +291,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction return; } - setDraftThreadContext(activeThreadRef, { + setDraftThreadContext(draftId ?? activeThreadRef, { branch, worktreePath: activeDraftThread.worktreePath, }); @@ -291,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction activeDraftThread, activeServerThread, activeThreadRef, + draftId, setDraftThreadContext, setThreadBranch, ], @@ -344,14 +354,18 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + const isSelectingWorktreeBase = + !activeServerThread && + activeDraftThread?.envMode === "worktree" && + activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning) { + if (isGitActionRunning || isSelectingWorktreeBase) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? null, + threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { @@ -361,8 +375,10 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction persistThreadBranchSync(branchUpdate.branch); }, [ activeServerThread?.branch, + activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, + isSelectingWorktreeBase, persistThreadBranchSync, ]); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 32f2f68e4a..223f5d8ebd 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -80,6 +80,8 @@ function createBaseServerConfig(): ServerConfig { auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], + slashCommands: [], + skills: [], }, ], availableEditors: [], diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b5871dd408..dfcf776d5a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -8,6 +8,7 @@ import type { ProviderInteractionMode, ProviderKind, RuntimeMode, + ServerProviderSkill, ScopedThreadRef, ServerProvider, ThreadId, @@ -55,8 +56,6 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { - resolveComposerFooterContentWidth, - shouldForceCompactComposerFooterForFit, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, } from "../composerFooterLayout"; @@ -69,6 +68,8 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; +import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { getComposerProviderState, renderProviderTraitsMenuContent, @@ -100,9 +101,20 @@ import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import type { ComposerFileReference } from "../../t3code-custom/file-references"; -import { useComposerCustomExtension, useComposerSkillExtension } from "../../t3code-custom/chat"; +import { + resolveComposerPlaceholder, + useComposerCustomExtension, + useComposerSkillExtension, +} from "../../t3code-custom/chat"; +import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; +import { searchProviderSkills } from "../../providerSkillSearch"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +// t3code note: the trailing Loop control is local custom UI, so the upstream +// footer budget needs this tiny allowance during plan follow-up layouts. +// Recheck this when syncing ChatComposer from upstream so the 804px overflow +// case keeps compacting without collapsing the wider layouts too early. +const PLAN_FOLLOW_UP_CUSTOM_FOOTER_ALLOWANCE_PX = 8; const runtimeModeConfig: Record< RuntimeMode, @@ -128,23 +140,8 @@ const runtimeModeConfig: Record< const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; -const EMPTY_PROVIDER_COMMANDS: ReadonlyArray = Object.freeze([]); -const BUILT_IN_SLASH_COMMANDS = ["model", "plan", "default"] as const; - -function uniqueStrings(values: ReadonlyArray): string[] { - return [...new Set(values)]; -} - -function buildDefaultComposerPlaceholder(provider: ProviderKind, phase: SessionPhase): string { - if (phase === "disconnected") { - return provider === "codex" - ? "Ask for follow-up changes, type $ to mention skills, or attach images" - : "Ask for follow-up changes or attach images"; - } - return provider === "codex" - ? "Ask anything, @tag files/folders, type $ to mention skills, or use / to show available commands" - : "Ask anything, @tag files/folders, or use / to show available commands"; -} +const EMPTY_PROVIDER_COMMAND_ENTRIES: ReadonlyArray = Object.freeze([]); +const EMPTY_PROVIDER_SKILLS: ReadonlyArray = Object.freeze([]); const extendReplacementRangeForTrailingSpace = ( text: string, @@ -594,6 +591,10 @@ export const ChatComposer = memo( }); const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); + const selectedProviderStatus = useMemo( + () => providerStatuses.find((provider) => provider.provider === selectedProvider), + [providerStatuses, selectedProvider], + ); const composerProviderState = useMemo( () => @@ -660,62 +661,62 @@ export const ChatComposer = memo( [activeThreadActivities], ); - const providerCommandsQuery = useQuery( + const codexDiscoveryQuery = useQuery( providerCommandsQueryOptions({ environmentId, provider: selectedProvider, cwd: gitCwd, - enabled: true, + enabled: selectedProvider === "codex", }), ); - const discoveredProviderCommands = - providerCommandsQuery.data?.commands ?? EMPTY_PROVIDER_COMMANDS; - const discoveredProviderSkills = providerCommandsQuery.data?.skills ?? EMPTY_PROVIDER_COMMANDS; - const discoveredSlashCommands = useMemo( + const codexDiscoveredCommands = + codexDiscoveryQuery.data?.commands ?? EMPTY_PROVIDER_COMMAND_ENTRIES; + const codexDiscoveredSkills = useMemo>( + () => + selectedProvider === "codex" + ? (codexDiscoveryQuery.data?.skills.flatMap((entry) => + typeof entry.path === "string" + ? [ + { + name: entry.name, + path: entry.path, + enabled: true, + ...(entry.description ? { description: entry.description } : {}), + scope: entry.source, + } satisfies ServerProviderSkill, + ] + : [], + ) ?? EMPTY_PROVIDER_SKILLS) + : EMPTY_PROVIDER_SKILLS, + [codexDiscoveryQuery.data?.skills, selectedProvider], + ); + const availableComposerSkills = useMemo( () => - uniqueStrings([ - ...BUILT_IN_SLASH_COMMANDS, - ...discoveredProviderCommands.map((entry) => entry.name), - ...(selectedProvider === "claudeAgent" - ? discoveredProviderSkills.map((entry) => entry.name) - : []), - ]), - [discoveredProviderCommands, discoveredProviderSkills, selectedProvider], + selectedProvider === "codex" + ? codexDiscoveredSkills + : (selectedProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS), + [codexDiscoveredSkills, selectedProvider, selectedProviderStatus], ); const composerSkillExtension = useComposerSkillExtension({ selectedProvider, prompt, - discoveredProviderSkills, + availableSkills: availableComposerSkills, }); - const composerCustomTokenTexts = composerSkillExtension.customTokenTexts; const collapseComposerCursor = useCallback( - (text: string, cursorInput: number) => - collapseExpandedComposerCursor(text, cursorInput, { - customTokenTexts: composerCustomTokenTexts, - }), - [composerCustomTokenTexts], + (text: string, cursorInput: number) => collapseExpandedComposerCursor(text, cursorInput), + [], ); const clampComposerCursor = useCallback( - (text: string, cursorInput: number) => - clampCollapsedComposerCursor(text, cursorInput, { - customTokenTexts: composerCustomTokenTexts, - }), - [composerCustomTokenTexts], + (text: string, cursorInput: number) => clampCollapsedComposerCursor(text, cursorInput), + [], ); const expandComposerCursor = useCallback( - (text: string, cursorInput: number) => - expandCollapsedComposerCursor(text, cursorInput, { - customTokenTexts: composerCustomTokenTexts, - }), - [composerCustomTokenTexts], + (text: string, cursorInput: number) => expandCollapsedComposerCursor(text, cursorInput), + [], ); const detectComposerTriggerForContext = useCallback( - (text: string, cursorInput: number) => - detectComposerTrigger(text, cursorInput, { - slashCommands: discoveredSlashCommands, - enableSkillTrigger: selectedProvider === "codex", - }), - [discoveredSlashCommands, selectedProvider], + (text: string, cursorInput: number) => detectComposerTrigger(text, cursorInput), + [], ); // ------------------------------------------------------------------ @@ -726,6 +727,9 @@ export const ChatComposer = memo( ); const [composerTrigger, setComposerTrigger] = useState(null); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( + null, + ); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); @@ -735,9 +739,6 @@ export const ChatComposer = memo( const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); - const composerFooterRef = useRef(null); - const composerFooterLeadingRef = useRef(null); - const composerFooterActionsRef = useRef(null); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -762,9 +763,7 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; - const skillTriggerQuery = composerTrigger?.kind === "skill" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; - const isSkillTrigger = composerTriggerKind === "skill"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, @@ -795,7 +794,7 @@ export const ChatComposer = memo( })); } if (composerTrigger.kind === "slash-command") { - const slashCommandItems: ComposerCommandItem[] = [ + const builtInSlashCommandItems = [ { id: "slash:model", type: "slash-command", @@ -817,41 +816,54 @@ export const ChatComposer = memo( label: "/default", description: "Switch this thread back to normal build mode", }, - ...discoveredProviderCommands.map((entry) => ({ - id: `slash:${selectedProvider}:${entry.source}:${entry.name}`, - type: "slash-command" as const, - command: entry.name, - label: `/${entry.name}`, - description: - entry.description || - (entry.source === "project" ? "Project command" : "User command"), - })), - ...(selectedProvider === "claudeAgent" - ? discoveredProviderSkills.map((entry) => ({ - id: `slash-skill:${selectedProvider}:${entry.source}:${entry.name}`, + ] satisfies ReadonlyArray>; + const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( + (command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + }), + ); + const codexDiscoveredCommandItems = + selectedProvider === "codex" + ? codexDiscoveredCommands.map((entry) => ({ + id: `slash:${selectedProvider}:${entry.source}:${entry.name}`, type: "slash-command" as const, command: entry.name, label: `/${entry.name}`, description: entry.description || - (entry.source === "builtin" - ? "Built-in Claude command" - : entry.source === "project" - ? "Project Claude skill" - : "User Claude skill"), + (entry.source === "project" ? "Project command" : "User command"), })) - : []), - ]; + : []; const query = composerTrigger.query.trim().toLowerCase(); + const slashCommandItems = [ + ...builtInSlashCommandItems, + ...providerSlashCommandItems, + ...codexDiscoveredCommandItems, + ]; if (!query) { return slashCommandItems; } - return slashCommandItems.filter((item) => - `${item.label} ${item.description}`.toLowerCase().includes(query), - ); + return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return composerSkillExtension.getSkillMenuItems(skillTriggerQuery); + return searchProviderSkills(availableComposerSkills, composerTrigger.query).map( + (skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + }), + ); } return searchableModelOptions .filter(({ searchSlug, searchName, searchProvider }) => { @@ -872,24 +884,33 @@ export const ChatComposer = memo( description: `${providerLabel} · ${slug}`, })); }, [ + availableComposerSkills, + codexDiscoveredCommands, composerTrigger, - composerSkillExtension, - discoveredProviderCommands, - discoveredProviderSkills, searchableModelOptions, selectedProvider, - skillTriggerQuery, + selectedProviderStatus, workspaceEntries, ]); const composerMenuOpen = Boolean(composerTrigger); - const activeComposerMenuItem = useMemo( - () => - composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? - composerMenuItems[0] ?? - null, - [composerHighlightedItemId, composerMenuItems], - ); + const composerMenuSearchKey = composerTrigger + ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` + : null; + const activeComposerMenuItem = useMemo(() => { + const activeItemId = resolveComposerMenuActiveItemId({ + items: composerMenuItems, + highlightedItemId: composerHighlightedItemId, + currentSearchKey: composerMenuSearchKey, + highlightedSearchKey: composerHighlightedSearchKey, + }); + return composerMenuItems.find((item) => item.id === activeItemId) ?? null; + }, [ + composerHighlightedItemId, + composerHighlightedSearchKey, + composerMenuItems, + composerMenuSearchKey, + ]); composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; @@ -937,16 +958,24 @@ export const ChatComposer = memo( ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching)) || - ((composerTriggerKind === "slash-command" || isSkillTrigger) && - (providerCommandsQuery.isLoading || providerCommandsQuery.isFetching)); + ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && + selectedProvider === "codex" && + (codexDiscoveryQuery.isLoading || codexDiscoveryQuery.isFetching)); + const composerMenuEmptyState = useMemo(() => { + if (composerTriggerKind === "skill") { + return "No skills found. Try / to browse provider commands."; + } + return composerTriggerKind === "path" + ? "No matching files or folders." + : "No matching command."; + }, [composerTriggerKind]); // ------------------------------------------------------------------ // Provider traits UI // ------------------------------------------------------------------ const setPromptFromTraits = useCallback( (nextPrompt: string) => { - const currentPrompt = promptRef.current; - if (nextPrompt === currentPrompt) { + if (nextPrompt === promptRef.current) { scheduleComposerFocus(); return; } @@ -1083,14 +1112,28 @@ export const ChatComposer = memo( useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); + setComposerHighlightedSearchKey(null); return; } + const nextActiveItemId = resolveComposerMenuActiveItemId({ + items: composerMenuItems, + highlightedItemId: composerHighlightedItemId, + currentSearchKey: composerMenuSearchKey, + highlightedSearchKey: composerHighlightedSearchKey, + }); setComposerHighlightedItemId((existing) => - existing && composerMenuItems.some((item) => item.id === existing) - ? existing - : (composerMenuItems[0]?.id ?? null), + existing === nextActiveItemId ? existing : nextActiveItemId, + ); + setComposerHighlightedSearchKey((existing) => + existing === composerMenuSearchKey ? existing : composerMenuSearchKey, ); - }, [composerMenuItems, composerMenuOpen]); + }, [ + composerHighlightedItemId, + composerHighlightedSearchKey, + composerMenuItems, + composerMenuOpen, + composerMenuSearchKey, + ]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; @@ -1171,31 +1214,24 @@ export const ChatComposer = memo( const measureComposerFormWidth = () => composerForm.clientWidth; const measureFooterCompactness = () => { const composerFormWidth = measureComposerFormWidth(); - const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { + // Sync reminder: this budget tweak exists only because Loop is appended + // after the upstream controls in the non-custom composer footer. + const composerFooterBudgetWidth = Math.max( + 0, + composerFormWidth - + (showPlanFollowUpPrompt ? PLAN_FOLLOW_UP_CUSTOM_FOOTER_ALLOWANCE_PX : 0), + ); + const footerCompact = shouldUseCompactComposerFooter(composerFooterBudgetWidth, { hasWideActions: composerFooterHasWideActions, }); - const footer = composerFooterRef.current; - const footerStyle = footer ? window.getComputedStyle(footer) : null; - const footerContentWidth = resolveComposerFooterContentWidth({ - footerWidth: footer?.clientWidth ?? null, - paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, - paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, - }); - const fitInput = { - footerContentWidth, - leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, - actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, - }; - const nextFooterCompact = - heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); - const nextPrimaryActionsCompact = - nextFooterCompact && - shouldUseCompactComposerPrimaryActions(composerFormWidth, { + const primaryActionsCompact = + footerCompact && + shouldUseCompactComposerPrimaryActions(composerFooterBudgetWidth, { hasWideActions: composerFooterHasWideActions, }); return { - primaryActionsCompact: nextPrimaryActionsCompact, - footerCompact: nextFooterCompact, + primaryActionsCompact, + footerCompact, }; }; @@ -1234,6 +1270,7 @@ export const ChatComposer = memo( composerFooterActionLayoutKey, composerFooterHasWideActions, scheduleStickToBottom, + showPlanFollowUpPrompt, shouldAutoScrollRef, ]); @@ -1467,7 +1504,6 @@ export const ChatComposer = memo( setThreadError, focusComposer, }); - const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; @@ -1552,29 +1588,40 @@ export const ChatComposer = memo( } return; } - if (item.type === "skill") { - const replacement = `$${item.name} `; + if (item.type === "provider-slash-command") { + const replacement = `/${item.command.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, replacement, ); - const next = replaceTextRange( + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "skill") { + const replacement = `$${item.skill.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( trigger.rangeStart, replacementRangeEnd, replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); - const nextCursor = collapseComposerCursor(next.text, next.cursor); - const nextExpandedCursor = expandComposerCursor(next.text, nextCursor); - promptRef.current = next.text; - setPrompt(next.text); - setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTriggerForContext(next.text, nextExpandedCursor)); - setComposerHighlightedItemId(null); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCursor); - }); + if (applied) { + setComposerHighlightedItemId(null); + } return; } onProviderModelSelect(item.provider, item.model); @@ -1587,20 +1634,19 @@ export const ChatComposer = memo( }, [ applyPromptReplacement, - collapseComposerCursor, - detectComposerTriggerForContext, - expandComposerCursor, handleInteractionModeChange, onProviderModelSelect, - promptRef, resolveActiveComposerTrigger, - setPrompt, ], ); - const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { - setComposerHighlightedItemId(itemId); - }, []); + const onComposerMenuItemHighlighted = useCallback( + (itemId: string | null) => { + setComposerHighlightedItemId(itemId); + setComposerHighlightedSearchKey(composerMenuSearchKey); + }, + [composerMenuSearchKey], + ); const nudgeComposerMenuHighlight = useCallback( (key: "ArrowDown" | "ArrowUp") => { @@ -1836,6 +1882,11 @@ export const ChatComposer = memo( resolvedTheme={resolvedTheme} isLoading={isComposerMenuLoading} triggerKind={composerTriggerKind} + groupSlashCommandSections={ + composerTrigger?.kind === "slash-command" && + composerTrigger.query.trim().length === 0 + } + emptyStateText={composerMenuEmptyState} activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlighted} onSelect={onSelectComposerItem} @@ -1929,7 +1980,7 @@ export const ChatComposer = memo( ? composerTerminalContexts : [] } - customTokenTexts={composerCustomTokenTexts} + skills={selectedProviderStatus?.skills ?? []} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} @@ -1942,7 +1993,7 @@ export const ChatComposer = memo( ? "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" - : buildDefaultComposerPlaceholder(selectedProvider, phase) + : resolveComposerPlaceholder(selectedProvider, phase) } disabled={isConnecting || isComposerApprovalState} /> @@ -1959,7 +2010,6 @@ export const ChatComposer = memo( ) : (
-
+
- {customExtension.compactControls} + {customExtension.compactControls} ) : ( <> @@ -2017,7 +2059,6 @@ export const ChatComposer = memo( {providerTraitsPicker} ) : null} - {customExtension.controls} + {customExtension.controls ? ( + <> + + {customExtension.controls} + + ) : null} )}
{/* Right side: send / stop button */}
)} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 610cb7d76d..2d39489b97 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,11 +1,24 @@ -import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useRef } from "react"; -import { type ComposerTriggerKind } from "../../composer-logic"; +import { + type ProjectEntry, + type ProviderKind, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; import { BotIcon } from "lucide-react"; +import { memo, useLayoutEffect, useMemo, useRef } from "react"; + +import { type ComposerTriggerKind } from "../../composer-logic"; +import { formatProviderSkillInstallSource } from "~/providerSkillPresentation"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; -import { Command, CommandItem, CommandList } from "../ui/command"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + Command, + CommandGroup, + CommandGroupLabel, + CommandItem, + CommandList, + CommandSeparator, +} from "../ui/command"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = @@ -26,10 +39,9 @@ export type ComposerCommandItem = } | { id: string; - type: "skill"; + type: "provider-slash-command"; provider: ProviderKind; - name: string; - path: string; + command: ServerProviderSlashCommand; label: string; description: string; } @@ -40,18 +52,83 @@ export type ComposerCommandItem = model: string; label: string; description: string; + } + | { + id: string; + type: "skill"; + provider: ProviderKind; + skill: ServerProviderSkill; + label: string; + description: string; }; +type ComposerCommandGroup = { + id: string; + label: string | null; + items: ComposerCommandItem[]; +}; + +function SkillGlyph(props: { className?: string }) { + return ( + + ); +} + +function groupCommandItems( + items: ComposerCommandItem[], + triggerKind: ComposerTriggerKind | null, + groupSlashCommandSections: boolean, +): ComposerCommandGroup[] { + if (triggerKind === "skill") { + return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : []; + } + if (triggerKind !== "slash-command" || !groupSlashCommandSections) { + return [{ id: "default", label: null, items }]; + } + + const builtInItems = items.filter((item) => item.type === "slash-command"); + const providerItems = items.filter((item) => item.type === "provider-slash-command"); + + const groups: ComposerCommandGroup[] = []; + if (builtInItems.length > 0) { + groups.push({ id: "built-in", label: "Built-in", items: builtInItems }); + } + if (providerItems.length > 0) { + groups.push({ id: "provider", label: "Provider", items: providerItems }); + } + return groups; +} + export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; + groupSlashCommandSections?: boolean; + emptyStateText?: string; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { const listRef = useRef(null); + const groups = useMemo( + () => + groupCommandItems(props.items, props.triggerKind, props.groupSlashCommandSections ?? true), + [props.groupSlashCommandSections, props.items, props.triggerKind], + ); useLayoutEffect(() => { if (!props.activeItemId || !listRef.current) return; @@ -75,33 +152,56 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ref={listRef} className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" > - - {props.items.map((item) => ( - + + {groups.map((group, groupIndex) => ( +
+ {groupIndex > 0 ? : null} + + {group.label ? ( + + {group.label} + + ) : null} + {group.items.map((item) => ( + + ))} + +
))}
- {props.items.length === 0 && ( -

- {props.isLoading - ? props.triggerKind === "path" - ? "Searching workspace files..." - : props.triggerKind === "skill" - ? "Loading skills..." - : "Loading commands..." - : props.triggerKind === "path" - ? "No matching files or folders." - : props.triggerKind === "skill" - ? "No matching skill." - : "No matching command."} -

- )} + {props.items.length === 0 ? ( +
+ {props.triggerKind === "skill" ? ( + + + Skills + +

+ {props.isLoading + ? "Searching workspace skills..." + : (props.emptyStateText ?? + "No skills found. Try / to browse provider commands.")} +

+
+ ) : ( +

+ {props.isLoading + ? "Searching workspace files..." + : (props.emptyStateText ?? + (props.triggerKind === "path" + ? "No matching files or folders." + : "No matching command."))} +

+ )} +
+ ) : null}
); @@ -114,7 +214,10 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { onHighlight: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { - const itemElement = ( + const skillSourceLabel = + props.item.type === "skill" ? formatProviderSkillInstallSource(props.item.skill) : null; + + return ( ) : null} {props.item.type === "slash-command" ? ( - + + ) : null} + {props.item.type === "provider-slash-command" ? ( + + + + ) : null} + {props.item.type === "skill" ? ( + + + ) : null} {props.item.type === "model" ? ( model ) : null} - - {props.item.label} - - - {props.item.description} + + {props.item.label} + + {props.item.description} + + {skillSourceLabel ? ( + {skillSourceLabel} + ) : null} ); - - if (props.item.type !== "skill") { - return itemElement; - } - - return ( - - - -
{props.item.label}
-
{props.item.description}
-
-
- ); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 008c1596dd..bb536dcfb6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -146,7 +146,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index 9efe8ccb4f..53ba9b0645 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -51,6 +51,13 @@ interface VirtualizerSnapshot { }>; } +function browserEstimateTolerance(base: number, linux: number): number { + // t3code note: the Linux GitHub runner wraps timeline text more aggressively + // than the local macOS browser harness, so this keeps CI checking the same + // behavior without pretending both platforms share identical font metrics. + return /Linux/i.test(globalThis.navigator?.userAgent ?? "") ? linux : base; +} + function MessagesTimelineBrowserHarness( props: Omit< ComponentProps, @@ -61,6 +68,9 @@ function MessagesTimelineBrowserHarness( const [expandedWorkGroups, setExpandedWorkGroups] = useState>( () => props.expandedWorkGroups, ); + const [changedFilesExpandedByTurnId, setChangedFilesExpandedByTurnId] = useState< + Record + >(() => props.changedFilesExpandedByTurnId); const handleToggleWorkGroup = useCallback( (groupId: string) => { setExpandedWorkGroups((current) => ({ @@ -71,6 +81,16 @@ function MessagesTimelineBrowserHarness( }, [props], ); + const handleSetChangedFilesExpanded = useCallback( + (turnId: TurnId, expanded: boolean) => { + setChangedFilesExpandedByTurnId((current) => ({ + ...current, + [turnId]: expanded, + })); + props.onSetChangedFilesExpanded(turnId, expanded); + }, + [props], + ); return (
); @@ -168,6 +190,8 @@ function createBaseTimelineProps(input: { nowIso: isoAt(10_000), expandedWorkGroups: input.expandedWorkGroups ?? {}, onToggleWorkGroup: () => {}, + changedFilesExpandedByTurnId: {}, + onSetChangedFilesExpanded: () => {}, onOpenTurnDiff: () => {}, revertTurnCountByUserMessageId: new Map(), onRevertUserMessage: () => {}, @@ -341,7 +365,7 @@ function buildStaticScenarios(): VirtualizationScenario[] { props: createBaseTimelineProps({ messages: [...beforeMessages, longUserMessage, ...afterMessages], }), - maxEstimateDeltaPx: 56, + maxEstimateDeltaPx: browserEstimateTolerance(56, 140), }, { name: "grouped work log row", diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 13fe6faba2..abedcd6eeb 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -24,6 +24,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], models: [ { slug: "gpt-5-codex", @@ -59,6 +61,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], models: [ { slug: "claude-opus-4-6", @@ -120,6 +124,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), models, + slashCommands: [], + skills: [], }; } diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index c79ab01dd3..6bb15ad649 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -44,6 +44,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + slashCommands: [], + skills: [], models: [ { slug: "gpt-5.4", @@ -70,6 +72,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + slashCommands: [], + skills: [], models: [ { slug: "claude-opus-4-6", diff --git a/apps/web/src/components/chat/composerMenuHighlight.test.ts b/apps/web/src/components/chat/composerMenuHighlight.test.ts new file mode 100644 index 0000000000..08c0f2f24d --- /dev/null +++ b/apps/web/src/components/chat/composerMenuHighlight.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; + +describe("resolveComposerMenuActiveItemId", () => { + const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const; + + it("defaults to the first item when nothing is highlighted", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: null, + currentSearchKey: "skill:u", + highlightedSearchKey: null, + }), + ).toBe("top"); + }); + + it("preserves the highlighted item within the same query", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "second", + currentSearchKey: "skill:u", + highlightedSearchKey: "skill:u", + }), + ).toBe("second"); + }); + + it("resets to the top result when the query changes", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "second", + currentSearchKey: "skill:ui", + highlightedSearchKey: "skill:u", + }), + ).toBe("top"); + }); + + it("falls back to the first item when the highlighted item disappears", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "missing", + currentSearchKey: "skill:ui", + highlightedSearchKey: "skill:ui", + }), + ).toBe("top"); + }); +}); diff --git a/apps/web/src/components/chat/composerMenuHighlight.ts b/apps/web/src/components/chat/composerMenuHighlight.ts new file mode 100644 index 0000000000..3cc3d4324f --- /dev/null +++ b/apps/web/src/components/chat/composerMenuHighlight.ts @@ -0,0 +1,20 @@ +export function resolveComposerMenuActiveItemId(input: { + items: ReadonlyArray<{ id: string }>; + highlightedItemId: string | null; + currentSearchKey: string | null; + highlightedSearchKey: string | null; +}): string | null { + if (input.items.length === 0) { + return null; + } + + if ( + input.currentSearchKey === input.highlightedSearchKey && + input.highlightedItemId && + input.items.some((item) => item.id === input.highlightedItemId) + ) { + return input.highlightedItemId; + } + + return input.items[0]?.id ?? null; +} diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts new file mode 100644 index 0000000000..3da69933d0 --- /dev/null +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; +import { searchSlashCommandItems } from "./composerSlashCommandSearch"; + +describe("searchSlashCommandItems", () => { + it("moves exact provider command matches ahead of broader description matches", () => { + const items = [ + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + { + id: "provider-slash-command:claudeAgent:ui", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "ui" }, + label: "/ui", + description: "Explore, build, and refine UI.", + }, + { + id: "provider-slash-command:claudeAgent:frontend-design", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "frontend-design" }, + label: "/frontend-design", + description: "Create distinctive, production-grade frontend interfaces", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([ + "provider-slash-command:claudeAgent:ui", + "slash:default", + ]); + }); + + it("supports fuzzy provider command matches", () => { + const items = [ + { + id: "provider-slash-command:claudeAgent:gh-fix-ci", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "gh-fix-ci" }, + label: "/gh-fix-ci", + description: "Fix failing GitHub Actions", + }, + { + id: "provider-slash-command:claudeAgent:github", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "github" }, + label: "/github", + description: "General GitHub help", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([ + "provider-slash-command:claudeAgent:gh-fix-ci", + ]); + }); +}); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts new file mode 100644 index 0000000000..c4919b1924 --- /dev/null +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -0,0 +1,83 @@ +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; + +function scoreSlashCommandItem( + item: Extract, + query: string, +): number | null { + const primaryValue = + item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); + const description = item.description.toLowerCase(); + + const scores = [ + scoreQueryMatch({ + value: primaryValue, + query, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: description, + query, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; + } + + return Math.min(...scores); +} + +export function searchSlashCommandItems( + items: ReadonlyArray< + Extract + >, + query: string, +): Array> { + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); + if (!normalizedQuery) { + return [...items]; + } + + const ranked: Array<{ + item: Extract; + score: number; + tieBreaker: string; + }> = []; + + for (const item of items) { + const score = scoreSlashCommandItem(item, normalizedQuery); + if (score === null) { + continue; + } + + insertRankedSearchResult( + ranked, + { + item, + score, + tieBreaker: + item.type === "slash-command" + ? `0\u0000${item.command}` + : `1\u0000${item.command.name}\u0000${item.provider}`, + }, + Number.POSITIVE_INFINITY, + ); + } + + return ranked.map((entry) => entry.item); +} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index d269fafbbf..0a019f6f33 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -4,9 +4,6 @@ import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, - measureComposerFooterOverflowPx, - resolveComposerFooterContentWidth, - shouldForceCompactComposerFooterForFit, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, } from "./composerFooterLayout"; @@ -56,99 +53,3 @@ describe("shouldUseCompactComposerPrimaryActions", () => { ).toBe(false); }); }); - -describe("measureComposerFooterOverflowPx", () => { - it("returns the overflow amount when content exceeds the footer width", () => { - expect( - measureComposerFooterOverflowPx({ - footerContentWidth: 500, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(28); - }); - - it("returns zero when content fits", () => { - expect( - measureComposerFooterOverflowPx({ - footerContentWidth: 500, - leadingContentWidth: 320, - actionsWidth: 160, - }), - ).toBe(0); - }); -}); - -describe("shouldForceCompactComposerFooterForFit", () => { - it("stays expanded when content widths fit within the footer", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 320, - actionsWidth: 160, - }), - ).toBe(false); - }); - - it("stays expanded when minor overflow can be recovered by compacting primary actions", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(false); - }); - - it("forces footer compact mode when action compaction would not recover enough space", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 420, - actionsWidth: 220, - }), - ).toBe(true); - }); - - it("ignores incomplete measurements", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: null, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(false); - }); -}); - -describe("resolveComposerFooterContentWidth", () => { - it("subtracts horizontal padding from the measured footer width", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: 500, - paddingLeft: 10, - paddingRight: 10, - }), - ).toBe(480); - }); - - it("clamps negative widths to zero", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: 10, - paddingLeft: 8, - paddingRight: 8, - }), - ).toBe(0); - }); - - it("returns null when measurements are incomplete", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: null, - paddingLeft: 8, - paddingRight: 8, - }), - ).toBeNull(); - }); -}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index b4a7fe3d60..0283d5d042 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,9 +1,7 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; -export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; +export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 820; export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX = COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; -const COMPOSER_FOOTER_CONTENT_GAP_PX = 8; -const COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX = 120; export function shouldUseCompactComposerFooter( width: number | null, @@ -24,46 +22,3 @@ export function shouldUseCompactComposerPrimaryActions( } return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX; } - -export function measureComposerFooterOverflowPx(input: { - footerContentWidth: number | null; - leadingContentWidth: number | null; - actionsWidth: number | null; -}): number | null { - const footerContentWidth = input.footerContentWidth; - const leadingContentWidth = input.leadingContentWidth; - const actionsWidth = input.actionsWidth; - if (footerContentWidth === null || leadingContentWidth === null || actionsWidth === null) { - return null; - } - return Math.max( - 0, - leadingContentWidth + actionsWidth + COMPOSER_FOOTER_CONTENT_GAP_PX - footerContentWidth, - ); -} - -export function shouldForceCompactComposerFooterForFit(input: { - footerContentWidth: number | null; - leadingContentWidth: number | null; - actionsWidth: number | null; -}): boolean { - const overflowPx = measureComposerFooterOverflowPx(input); - if (overflowPx === null) { - return false; - } - return overflowPx > COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX; -} - -export function resolveComposerFooterContentWidth(input: { - footerWidth: number | null; - paddingLeft: number | null; - paddingRight: number | null; -}): number | null { - const footerWidth = input.footerWidth; - const paddingLeft = input.paddingLeft; - const paddingRight = input.paddingRight; - if (footerWidth === null || paddingLeft === null || paddingRight === null) { - return null; - } - return Math.max(0, footerWidth - paddingLeft - paddingRight); -} diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index 273f4204e6..bf869ee31d 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -5,5 +5,8 @@ export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-8 export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight"; +export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = + "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; diff --git a/apps/web/src/components/composerInlineTextNodes.ts b/apps/web/src/components/composerInlineTextNodes.ts deleted file mode 100644 index 00c3f35f35..0000000000 --- a/apps/web/src/components/composerInlineTextNodes.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - $applyNodeReplacement, - TextNode, - type EditorConfig, - type NodeKey, - type SerializedTextNode, - type Spread, -} from "lexical"; - -import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; - -import { - COMPOSER_INLINE_CHIP_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, -} from "./composerInlineChip"; - -type SerializedComposerMentionNode = Spread< - { - path: string; - type: "composer-mention"; - version: 1; - }, - SerializedTextNode ->; - -type SerializedComposerCustomTokenNode = Spread< - { - tokenText: string; - type: "composer-custom-token"; - version: 1; - }, - SerializedTextNode ->; - -function resolvedThemeFromDocument(): "light" | "dark" { - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -function renderMentionChipDom(container: HTMLElement, pathValue: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); - - const theme = resolvedThemeFromDocument(); - const icon = document.createElement("img"); - icon.alt = ""; - icon.ariaHidden = "true"; - icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; - icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); - - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = basenameOfPath(pathValue); - - container.append(icon, label); -} - -function renderCustomTokenChipDom(container: HTMLElement, tokenText: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); - - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = tokenText; - - container.append(label); -} - -export class ComposerMentionNode extends TextNode { - __path: string; - - static override getType(): string { - return "composer-mention"; - } - - static override clone(node: ComposerMentionNode): ComposerMentionNode { - return new ComposerMentionNode(node.__path, node.__key); - } - - static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path); - } - - constructor(path: string, key?: NodeKey) { - const normalizedPath = path.startsWith("@") ? path.slice(1) : path; - super(`@${normalizedPath}`, key); - this.__path = normalizedPath; - } - - override exportJSON(): SerializedComposerMentionNode { - return { - ...super.exportJSON(), - path: this.__path, - type: "composer-mention", - version: 1, - }; - } - - override createDOM(_config: EditorConfig): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderMentionChipDom(dom, this.__path); - return dom; - } - - override updateDOM( - prevNode: ComposerMentionNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.contentEditable = "false"; - if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { - renderMentionChipDom(dom, this.__path); - } - return false; - } - - override canInsertTextBefore(): false { - return false; - } - - override canInsertTextAfter(): false { - return false; - } - - override isTextEntity(): true { - return true; - } - - override isToken(): true { - return true; - } -} - -export function $createComposerMentionNode(path: string): ComposerMentionNode { - return $applyNodeReplacement(new ComposerMentionNode(path)); -} - -export class ComposerCustomTokenNode extends TextNode { - __tokenText: string; - - static override getType(): string { - return "composer-custom-token"; - } - - static override clone(node: ComposerCustomTokenNode): ComposerCustomTokenNode { - return new ComposerCustomTokenNode(node.__tokenText, node.__key); - } - - static override importJSON( - serializedNode: SerializedComposerCustomTokenNode, - ): ComposerCustomTokenNode { - return $createComposerCustomTokenNode(serializedNode.tokenText); - } - - constructor(tokenText: string, key?: NodeKey) { - super(tokenText, key); - this.__tokenText = tokenText; - } - - override exportJSON(): SerializedComposerCustomTokenNode { - return { - ...super.exportJSON(), - tokenText: this.__tokenText, - type: "composer-custom-token", - version: 1, - }; - } - - override createDOM(_config: EditorConfig): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderCustomTokenChipDom(dom, this.__tokenText); - return dom; - } - - override updateDOM( - prevNode: ComposerCustomTokenNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.contentEditable = "false"; - if (prevNode.__text !== this.__text || prevNode.__tokenText !== this.__tokenText) { - renderCustomTokenChipDom(dom, this.__tokenText); - } - return false; - } - - override canInsertTextBefore(): false { - return false; - } - - override canInsertTextAfter(): false { - return false; - } - - override isTextEntity(): true { - return true; - } - - override isToken(): true { - return true; - } -} - -export function $createComposerCustomTokenNode(tokenText: string): ComposerCustomTokenNode { - return $applyNodeReplacement(new ComposerCustomTokenNode(tokenText)); -} - -export type ComposerInlineTextNode = ComposerMentionNode | ComposerCustomTokenNode; - -export function isComposerInlineTextNode(candidate: unknown): candidate is ComposerInlineTextNode { - return candidate instanceof ComposerMentionNode || candidate instanceof ComposerCustomTokenNode; -} diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 5761af867b..5a808bf6a2 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -17,7 +17,9 @@ const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; +// Browser parity measurements on the real timeline land closer to ~7.4px per +// wrapped user character once bubble chrome and container constraints kick in. +const USER_MONO_AVG_CHAR_WIDTH_PX = 7.4; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index e02f2981f2..d723114b40 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -29,32 +29,18 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); - it("splits codex skill tokens followed by whitespace into skill segments", () => { - expect( - splitPromptIntoComposerSegments("Use $review please", [], { - customTokenTexts: ["$review"], - }), - ).toEqual([ + it("splits skill tokens followed by whitespace into skill segments", () => { + expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([ { type: "text", text: "Use " }, - { type: "custom-token", tokenText: "$review" }, + { type: "skill", name: "review-follow-up" }, { type: "text", text: " please" }, ]); }); it("does not convert an incomplete trailing skill token", () => { - expect( - splitPromptIntoComposerSegments("Use $review", [], { - customTokenTexts: ["$review"], - }), - ).toEqual([{ type: "text", text: "Use $review" }]); - }); - - it("keeps unknown shell-style variables as text", () => { - expect( - splitPromptIntoComposerSegments("echo $HOME please", [], { - customTokenTexts: ["$review"], - }), - ).toEqual([{ type: "text", text: "echo $HOME please" }]); + expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([ + { type: "text", text: "Use $review-follow-up" }, + ]); }); it("keeps inline terminal context placeholders at their prompt positions", () => { @@ -81,6 +67,21 @@ describe("splitPromptIntoComposerSegments", () => { { type: "text", text: "tail" }, ]); }); + + it("keeps skill parsing alongside mentions and terminal placeholders", () => { + expect( + splitPromptIntoComposerSegments( + `Inspect ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}$review-follow-up after @AGENTS.md `, + ), + ).toEqual([ + { type: "text", text: "Inspect " }, + { type: "terminal-context", context: null }, + { type: "skill", name: "review-follow-up" }, + { type: "text", text: " after " }, + { type: "mention", path: "AGENTS.md" }, + { type: "text", text: " " }, + ]); + }); }); describe("selectionTouchesMentionBoundary", () => { @@ -124,15 +125,4 @@ describe("selectionTouchesMentionBoundary", () => { ), ).toBe(true); }); - - it("returns true when selection includes whitespace after a skill token", () => { - expect( - selectionTouchesMentionBoundary( - "use $review now", - "use $review".length, - "use $review now".length, - { customTokenTexts: ["$review"] }, - ), - ).toBe(true); - }); }); diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 54698c4c62..9f4492cabf 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -3,10 +3,6 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; -export interface ComposerPromptInlineTokenOptions { - customTokenTexts?: readonly string[]; -} - export type ComposerPromptSegment = | { type: "text"; @@ -17,8 +13,8 @@ export type ComposerPromptSegment = path: string; } | { - type: "custom-token"; - tokenText: string; + type: "skill"; + name: string; } | { type: "terminal-context"; @@ -26,24 +22,7 @@ export type ComposerPromptSegment = }; const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; - -function toCustomTokenTexts(tokenTexts?: readonly string[]): readonly string[] { - if (!tokenTexts || tokenTexts.length === 0) { - return []; - } - return [...new Set(tokenTexts)].toSorted((left, right) => right.length - left.length); -} - -function escapeRegexFragment(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function buildCustomTokenRegex(tokenTexts: readonly string[]): RegExp | null { - if (tokenTexts.length === 0) { - return null; - } - return new RegExp(`(^|\\s)(${tokenTexts.map(escapeRegexFragment).join("|")})(?=\\s)`, "g"); -} +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; function rangeIncludesIndex(start: number, end: number, index: number): boolean { return start <= index && index < end; @@ -59,6 +38,50 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void segments.push({ type: "text", text }); } +type InlineTokenMatch = + | { + type: "mention"; + value: string; + start: number; + end: number; + } + | { + type: "skill"; + value: string; + start: number; + end: number; + }; + +function collectInlineTokenMatches(text: string): InlineTokenMatch[] { + const matches: InlineTokenMatch[] = []; + + for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const path = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + if (path.length > 0) { + matches.push({ type: "mention", value: path, start, end }); + } + } + + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const skillName = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + if (skillName.length > 0) { + matches.push({ type: "skill", value: skillName, start, end }); + } + } + + return matches.toSorted((left, right) => left.start - right.start); +} + function forEachPromptSegmentSlice( prompt: string, visitor: ( @@ -137,84 +160,29 @@ function forEachMentionMatch( }); } -function forEachCustomTokenMatch( - prompt: string, - customTokenRegex: RegExp | null, - visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void, -): boolean { - if (customTokenRegex === null) { - return false; - } - return forEachPromptTextSlice(prompt, (text, promptOffset) => { - for (const match of text.matchAll(customTokenRegex)) { - if (visitor(match, promptOffset) === true) { - return true; - } - } - return false; - }); -} - -function splitPromptTextIntoComposerSegments( - text: string, - customTokenRegex: RegExp | null, -): ComposerPromptSegment[] { +function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { const segments: ComposerPromptSegment[] = []; if (!text) { return segments; } - const matches = [ - ...Array.from(text.matchAll(MENTION_TOKEN_REGEX), (match) => { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const path = match[2] ?? ""; - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - return { type: "mention" as const, value: path, start, end }; - }), - ...Array.from(text.matchAll(customTokenRegex ?? /$^/g), (match) => { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const tokenText = match[2] ?? ""; - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - return { type: "custom-token" as const, value: tokenText, start, end }; - }), - ] - .filter( - ( - match, - ): match is { - type: "mention" | "custom-token"; - value: string; - start: number; - end: number; - } => match !== null, - ) - .toSorted((left, right) => left.start - right.start); - + const tokenMatches = collectInlineTokenMatches(text); let cursor = 0; - for (const match of matches) { + for (const match of tokenMatches) { if (match.start < cursor) { continue; } + if (match.start > cursor) { pushTextSegment(segments, text.slice(cursor, match.start)); } + if (match.type === "mention") { - if (match.value.length > 0) { - segments.push({ type: "mention", path: match.value }); - } else { - pushTextSegment(segments, text.slice(match.start, match.end)); - } - } else if (match.value.length > 0) { - segments.push({ type: "custom-token", tokenText: match.value }); + segments.push({ type: "mention", path: match.value }); } else { - pushTextSegment(segments, text.slice(match.start, match.end)); + segments.push({ type: "skill", name: match.value }); } + cursor = match.end; } @@ -229,56 +197,42 @@ export function selectionTouchesMentionBoundary( prompt: string, start: number, end: number, - options?: ComposerPromptInlineTokenOptions, ): boolean { if (!prompt || start >= end) { return false; } - const customTokenRegex = buildCustomTokenRegex(toCustomTokenTexts(options?.customTokenTexts)); - const touchesBoundary = ( - match: RegExpMatchArray, - promptOffset: number, - prefixGroupIndex: number, - ) => { + return forEachMentionMatch(prompt, (match, promptOffset) => { const fullMatch = match[0]; - const prefix = match[prefixGroupIndex] ?? ""; + const prefix = match[1] ?? ""; const matchIndex = match.index ?? 0; - const tokenStart = promptOffset + matchIndex + prefix.length; - const tokenEnd = tokenStart + fullMatch.length - prefix.length; - const beforeTokenIndex = tokenStart - 1; - const afterTokenIndex = tokenEnd; + const mentionStart = promptOffset + matchIndex + prefix.length; + const mentionEnd = mentionStart + fullMatch.length - prefix.length; + const beforeMentionIndex = mentionStart - 1; + const afterMentionIndex = mentionEnd; if ( - beforeTokenIndex >= 0 && - /\s/.test(prompt[beforeTokenIndex] ?? "") && - rangeIncludesIndex(start, end, beforeTokenIndex) + beforeMentionIndex >= 0 && + /\s/.test(prompt[beforeMentionIndex] ?? "") && + rangeIncludesIndex(start, end, beforeMentionIndex) ) { return true; } if ( - afterTokenIndex < prompt.length && - /\s/.test(prompt[afterTokenIndex] ?? "") && - rangeIncludesIndex(start, end, afterTokenIndex) + afterMentionIndex < prompt.length && + /\s/.test(prompt[afterMentionIndex] ?? "") && + rangeIncludesIndex(start, end, afterMentionIndex) ) { return true; } return false; - }; - - return ( - forEachMentionMatch(prompt, (match, promptOffset) => touchesBoundary(match, promptOffset, 1)) || - forEachCustomTokenMatch(prompt, customTokenRegex, (match, promptOffset) => - touchesBoundary(match, promptOffset, 1), - ) - ); + }); } export function splitPromptIntoComposerSegments( prompt: string, terminalContexts: ReadonlyArray = [], - options?: ComposerPromptInlineTokenOptions, ): ComposerPromptSegment[] { if (!prompt) { return []; @@ -286,10 +240,9 @@ export function splitPromptIntoComposerSegments( const segments: ComposerPromptSegment[] = []; let terminalContextIndex = 0; - const customTokenRegex = buildCustomTokenRegex(toCustomTokenTexts(options?.customTokenTexts)); forEachPromptSegmentSlice(prompt, (slice) => { if (slice.type === "text") { - segments.push(...splitPromptTextIntoComposerSegments(slice.text, customTokenRegex)); + segments.push(...splitPromptTextIntoComposerSegments(slice.text)); return false; } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index ce693a7612..1c18af54e6 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -60,39 +60,30 @@ describe("detectComposerTrigger", () => { }); }); - it("detects provider-discovered slash commands while typing", () => { - const text = "/sim"; - const trigger = detectComposerTrigger(text, text.length, { - slashCommands: ["model", "plan", "default", "simplify"], - }); + it("keeps slash command detection active for provider commands", () => { + const text = "/rev"; + const trigger = detectComposerTrigger(text, text.length); expect(trigger).toEqual({ kind: "slash-command", - query: "sim", + query: "rev", rangeStart: 0, rangeEnd: text.length, }); }); - it("detects codex skill trigger while typing", () => { - const text = "Use $rev"; - const cursor = text.length; - const trigger = detectComposerTrigger(text, cursor, { enableSkillTrigger: true }); + it("detects $skill trigger at cursor", () => { + const text = "Use $gh-fi"; + const trigger = detectComposerTrigger(text, text.length); expect(trigger).toEqual({ kind: "skill", - query: "rev", + query: "gh-fi", rangeStart: "Use ".length, - rangeEnd: cursor, + rangeEnd: text.length, }); }); - it("does not collapse shell variables into inline tokens without a known skill catalog", () => { - expect(collapseExpandedComposerCursor("echo $HOME ", "echo $HOME ".length)).toBe( - "echo $HOME ".length, - ); - }); - it("detects @path trigger in the middle of existing text", () => { // User typed @ between "inspect " and "in this sentence" const text = "Please inspect @in this sentence"; @@ -166,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => { expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); }); + + it("maps collapsed skill cursor to expanded text cursor", () => { + const text = "run $review-follow-up then"; + const collapsedCursorAfterSkill = "run ".length + 2; + const expandedCursorAfterSkill = "run $review-follow-up ".length; + + expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe( + expandedCursorAfterSkill, + ); + }); }); describe("collapseExpandedComposerCursor", () => { @@ -191,6 +192,16 @@ describe("collapseExpandedComposerCursor", () => { expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); }); + + it("maps expanded skill cursor back to collapsed cursor", () => { + const text = "run $review-follow-up then"; + const collapsedCursorAfterSkill = "run ".length + 2; + const expandedCursorAfterSkill = "run $review-follow-up ".length; + + expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe( + collapsedCursorAfterSkill, + ); + }); }); describe("clampCollapsedComposerCursor", () => { @@ -266,6 +277,15 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => { expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true); expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true); }); + + it("treats skill pills as inline tokens for adjacency checks", () => { + const text = "run $review-follow-up next"; + const tokenStart = "run ".length; + const tokenEnd = tokenStart + 1; + + expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true); + expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true); + }); }); describe("parseStandaloneComposerSlashCommand", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 05023be34c..a5b26b0e2d 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,7 +1,4 @@ -import { - type ComposerPromptInlineTokenOptions, - splitPromptIntoComposerSegments, -} from "./composer-editor-mentions"; +import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; @@ -14,12 +11,11 @@ export interface ComposerTrigger { rangeEnd: number; } -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; const isInlineTokenSegment = ( segment: | { type: "text"; text: string } | { type: "mention" } - | { type: "custom-token"; tokenText: string } + | { type: "skill" } | { type: "terminal-context" }, ): boolean => segment.type !== "text"; @@ -46,13 +42,9 @@ function tokenStartForCursor(text: string, cursor: number): number { return index + 1; } -export function expandCollapsedComposerCursor( - text: string, - cursorInput: number, - options?: ComposerPromptInlineTokenOptions, -): number { +export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { const collapsedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text, [], options); + const segments = splitPromptIntoComposerSegments(text); if (segments.length === 0) { return collapsedCursor; } @@ -61,9 +53,17 @@ export function expandCollapsedComposerCursor( let expandedCursor = 0; for (const segment of segments) { - if (segment.type === "mention" || segment.type === "custom-token") { - const expandedLength = - segment.type === "mention" ? segment.path.length + 1 : segment.tokenText.length; + if (segment.type === "mention") { + const expandedLength = segment.path.length + 1; + if (remaining <= 1) { + return expandedCursor + (remaining === 0 ? 0 : expandedLength); + } + remaining -= 1; + expandedCursor += expandedLength; + continue; + } + if (segment.type === "skill") { + const expandedLength = segment.name.length + 1; if (remaining <= 1) { return expandedCursor + (remaining === 0 ? 0 : expandedLength); } @@ -95,7 +95,7 @@ function collapsedSegmentLength( segment: | { type: "text"; text: string } | { type: "mention" } - | { type: "custom-token"; tokenText: string } + | { type: "skill" } | { type: "terminal-context" }, ): number { if (segment.type === "text") { @@ -108,7 +108,7 @@ function clampCollapsedComposerCursorForSegments( segments: ReadonlyArray< | { type: "text"; text: string } | { type: "mention" } - | { type: "custom-token"; tokenText: string } + | { type: "skill" } | { type: "terminal-context" } >, cursorInput: number, @@ -123,24 +123,16 @@ function clampCollapsedComposerCursorForSegments( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } -export function clampCollapsedComposerCursor( - text: string, - cursorInput: number, - options?: ComposerPromptInlineTokenOptions, -): number { +export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { return clampCollapsedComposerCursorForSegments( - splitPromptIntoComposerSegments(text, [], options), + splitPromptIntoComposerSegments(text), cursorInput, ); } -export function collapseExpandedComposerCursor( - text: string, - cursorInput: number, - options?: ComposerPromptInlineTokenOptions, -): number { +export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { const expandedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text, [], options); + const segments = splitPromptIntoComposerSegments(text); if (segments.length === 0) { return expandedCursor; } @@ -149,9 +141,20 @@ export function collapseExpandedComposerCursor( let collapsedCursor = 0; for (const segment of segments) { - if (segment.type === "mention" || segment.type === "custom-token") { - const expandedLength = - segment.type === "mention" ? segment.path.length + 1 : segment.tokenText.length; + if (segment.type === "mention") { + const expandedLength = segment.path.length + 1; + if (remaining === 0) { + return collapsedCursor; + } + if (remaining <= expandedLength) { + return collapsedCursor + 1; + } + remaining -= expandedLength; + collapsedCursor += 1; + continue; + } + if (segment.type === "skill") { + const expandedLength = segment.name.length + 1; if (remaining === 0) { return collapsedCursor; } @@ -186,9 +189,8 @@ export function isCollapsedCursorAdjacentToInlineToken( text: string, cursorInput: number, direction: "left" | "right", - options?: ComposerPromptInlineTokenOptions, ): boolean { - const segments = splitPromptIntoComposerSegments(text, [], options); + const segments = splitPromptIntoComposerSegments(text); if (!segments.some(isInlineTokenSegment)) { return false; } @@ -213,18 +215,10 @@ export function isCollapsedCursorAdjacentToInlineToken( export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; -export function detectComposerTrigger( - text: string, - cursorInput: number, - options?: { - slashCommands?: readonly string[]; - enableSkillTrigger?: boolean; - }, -): ComposerTrigger | null { +export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; const linePrefix = text.slice(lineStart, cursor); - const slashCommands = options?.slashCommands ?? SLASH_COMMANDS; if (linePrefix.startsWith("/")) { const commandMatch = /^\/(\S*)$/.exec(linePrefix); @@ -238,15 +232,12 @@ export function detectComposerTrigger( rangeEnd: cursor, }; } - if (slashCommands.some((command) => command.startsWith(commandQuery.toLowerCase()))) { - return { - kind: "slash-command", - query: commandQuery, - rangeStart: lineStart, - rangeEnd: cursor, - }; - } - return null; + return { + kind: "slash-command", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); @@ -262,7 +253,7 @@ export function detectComposerTrigger( const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); - if (options?.enableSkillTrigger && token.startsWith("$")) { + if (token.startsWith("$")) { return { kind: "skill", query: token.slice(1), diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index b2b330ce75..ae2d6bd64d 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -16,6 +16,14 @@ import { } from "../../pairingUrl"; import { resolvePrimaryEnvironmentHttpUrl } from "./target"; +import { Data, Predicate } from "effect"; + +export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ + readonly message: string; + readonly status: number; +}> {} +const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => + Predicate.isTagged(u, "BootstrapHttpError"); export interface ServerPairingLinkRecord { readonly id: string; @@ -87,10 +95,10 @@ export async function fetchSessionState(): Promise { credentials: "include", }); if (!response.ok) { - throw new BootstrapHttpError( - `Failed to load server auth session state (${response.status}).`, - response.status, - ); + throw new BootstrapHttpError({ + message: `Failed to load server auth session state (${response.status}).`, + status: response.status, + }); } return (await response.json()) as AuthSessionState; }); @@ -115,10 +123,10 @@ async function exchangeBootstrapCredential(credential: string): Promise(operation: () => Promise): Promise { const startedAt = Date.now(); while (true) { @@ -182,7 +180,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (error instanceof BootstrapHttpError) { + if (isBootstrapHttpError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index df8c5d1dbb..dea1683c4d 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -52,10 +52,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], + slashCommands: [], + skills: [], }, ]; diff --git a/apps/web/src/providerSkillPresentation.test.ts b/apps/web/src/providerSkillPresentation.test.ts new file mode 100644 index 0000000000..ce94d88a6b --- /dev/null +++ b/apps/web/src/providerSkillPresentation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + formatProviderSkillDisplayName, + formatProviderSkillInstallSource, +} from "./providerSkillPresentation"; + +describe("formatProviderSkillDisplayName", () => { + it("prefers the provider display name", () => { + expect( + formatProviderSkillDisplayName({ + name: "review-follow-up", + displayName: "Review Follow-up", + }), + ).toBe("Review Follow-up"); + }); + + it("falls back to a title-cased skill name", () => { + expect( + formatProviderSkillDisplayName({ + name: "review-follow-up", + }), + ).toBe("Review Follow Up"); + }); +}); + +describe("formatProviderSkillInstallSource", () => { + it("marks plugin-backed skills as app installs", () => { + expect( + formatProviderSkillInstallSource({ + path: "/Users/julius/.codex/plugins/cache/openai-curated/github/skills/gh-fix-ci/SKILL.md", + scope: "user", + }), + ).toBe("App"); + }); + + it("maps standard scopes to user-facing labels", () => { + expect( + formatProviderSkillInstallSource({ + path: "/Users/julius/.agents/skills/agent-browser/SKILL.md", + scope: "user", + }), + ).toBe("Personal"); + expect( + formatProviderSkillInstallSource({ + path: "/usr/local/share/codex/skills/imagegen/SKILL.md", + scope: "system", + }), + ).toBe("System"); + expect( + formatProviderSkillInstallSource({ + path: "/workspace/.codex/skills/review-follow-up/SKILL.md", + scope: "project", + }), + ).toBe("Project"); + }); +}); diff --git a/apps/web/src/providerSkillPresentation.ts b/apps/web/src/providerSkillPresentation.ts new file mode 100644 index 0000000000..fe077cbb19 --- /dev/null +++ b/apps/web/src/providerSkillPresentation.ts @@ -0,0 +1,52 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; + +function titleCaseWords(value: string): string { + return value + .split(/[\s:_-]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function normalizePathSeparators(pathValue: string): string { + return pathValue.replaceAll("\\", "/"); +} + +export function formatProviderSkillDisplayName( + skill: Pick, +): string { + const displayName = skill.displayName?.trim(); + if (displayName) { + return displayName; + } + return titleCaseWords(skill.name); +} + +export function formatProviderSkillInstallSource( + skill: Pick, +): string | null { + const normalizedPath = normalizePathSeparators(skill.path); + if (normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/")) { + return "App"; + } + + const normalizedScope = skill.scope?.trim().toLowerCase(); + if (normalizedScope === "system") { + return "System"; + } + if ( + normalizedScope === "project" || + normalizedScope === "workspace" || + normalizedScope === "local" + ) { + return "Project"; + } + if (normalizedScope === "user" || normalizedScope === "personal") { + return "Personal"; + } + if (normalizedScope) { + return titleCaseWords(normalizedScope); + } + + return null; +} diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts new file mode 100644 index 0000000000..ede929c8d3 --- /dev/null +++ b/apps/web/src/providerSkillSearch.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; + +import { searchProviderSkills } from "./providerSkillSearch"; + +function makeSkill(input: Partial & Pick) { + return { + path: `/tmp/${input.name}/SKILL.md`, + enabled: true, + ...input, + } satisfies ServerProviderSkill; +} + +describe("searchProviderSkills", () => { + it("moves exact ui matches ahead of broader ui matches", () => { + const skills = [ + makeSkill({ + name: "agent-browser", + displayName: "Agent Browser", + shortDescription: "Browser automation CLI for AI agents", + }), + makeSkill({ + name: "building-native-ui", + displayName: "Building Native Ui", + shortDescription: "Complete guide for building beautiful apps with Expo Router", + }), + makeSkill({ + name: "ui", + displayName: "Ui", + shortDescription: "Explore, build, and refine UI.", + }), + ]; + + expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([ + "ui", + "building-native-ui", + ]); + }); + + it("uses fuzzy ranking for abbreviated queries", () => { + const skills = [ + makeSkill({ name: "gh-fix-ci", displayName: "Gh Fix Ci" }), + makeSkill({ name: "github", displayName: "Github" }), + makeSkill({ name: "agent-browser", displayName: "Agent Browser" }), + ]; + + expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]); + }); + + it("omits disabled skills from results", () => { + const skills = [ + makeSkill({ name: "ui", displayName: "Ui", enabled: false }), + makeSkill({ name: "frontend-design", displayName: "Frontend Design" }), + ]; + + expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]); + }); +}); diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts new file mode 100644 index 0000000000..2391e81813 --- /dev/null +++ b/apps/web/src/providerSkillSearch.ts @@ -0,0 +1,105 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; + +import { formatProviderSkillDisplayName } from "./providerSkillPresentation"; + +function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null { + const normalizedName = skill.name.toLowerCase(); + const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase(); + const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? ""; + const normalizedDescription = skill.description?.toLowerCase() ?? ""; + const normalizedScope = skill.scope?.toLowerCase() ?? ""; + + const scores = [ + scoreQueryMatch({ + value: normalizedName, + query, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: normalizedLabel, + query, + exactBase: 1, + prefixBase: 3, + boundaryBase: 5, + includesBase: 7, + fuzzyBase: 110, + }), + scoreQueryMatch({ + value: normalizedShortDescription, + query, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + scoreQueryMatch({ + value: normalizedDescription, + query, + exactBase: 30, + prefixBase: 32, + boundaryBase: 34, + includesBase: 36, + }), + scoreQueryMatch({ + value: normalizedScope, + query, + exactBase: 40, + prefixBase: 42, + includesBase: 44, + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; + } + + return Math.min(...scores); +} + +export function searchProviderSkills( + skills: ReadonlyArray, + query: string, + limit = Number.POSITIVE_INFINITY, +): ServerProviderSkill[] { + const enabledSkills = skills.filter((skill) => skill.enabled); + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); + + if (!normalizedQuery) { + return enabledSkills; + } + + const ranked: Array<{ + item: ServerProviderSkill; + score: number; + tieBreaker: string; + }> = []; + + for (const skill of enabledSkills) { + const score = scoreProviderSkill(skill, normalizedQuery); + if (score === null) { + continue; + } + + insertRankedSearchResult( + ranked, + { + item: skill, + score, + tieBreaker: `${formatProviderSkillDisplayName(skill).toLowerCase()}\u0000${skill.name}`, + }, + limit, + ); + } + + return ranked.map((entry) => entry.item); +} diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 4df3c6927d..a587fcd9f1 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -48,6 +48,8 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], + slashCommands: [], + skills: [], }, ]; diff --git a/apps/web/src/t3code-custom/chat/composerPlaceholder.ts b/apps/web/src/t3code-custom/chat/composerPlaceholder.ts new file mode 100644 index 0000000000..576cb33056 --- /dev/null +++ b/apps/web/src/t3code-custom/chat/composerPlaceholder.ts @@ -0,0 +1,15 @@ +import type { ProviderKind } from "@t3tools/contracts"; + +import type { SessionPhase } from "~/types"; + +export function resolveComposerPlaceholder(provider: ProviderKind, phase: SessionPhase): string { + if (phase === "disconnected") { + return provider === "codex" + ? "Ask for follow-up changes, type $ to mention skills, or attach images" + : "Ask for follow-up changes or attach images"; + } + + return provider === "codex" + ? "Ask anything, @tag files/folders, type $ to mention skills, or use / to show available commands" + : "Ask anything, @tag files/folders, or use / to show available commands"; +} diff --git a/apps/web/src/t3code-custom/chat/index.ts b/apps/web/src/t3code-custom/chat/index.ts index fdad7caa9c..af7faee0cd 100644 --- a/apps/web/src/t3code-custom/chat/index.ts +++ b/apps/web/src/t3code-custom/chat/index.ts @@ -1,5 +1,6 @@ export { ComposerCustomBodySlot } from "./ComposerCustomBodySlot"; export { ComposerCustomControlsSlot } from "./ComposerCustomControlsSlot"; export { ComposerThreadLoopSlot } from "./ComposerThreadLoopSlot"; +export { resolveComposerPlaceholder } from "./composerPlaceholder"; export { useComposerCustomExtension } from "./useComposerCustomExtension"; export { useComposerSkillExtension } from "./useComposerSkillExtension"; diff --git a/apps/web/src/t3code-custom/chat/useComposerSkillExtension.ts b/apps/web/src/t3code-custom/chat/useComposerSkillExtension.ts index 30fc187f94..bdf63ad4ed 100644 --- a/apps/web/src/t3code-custom/chat/useComposerSkillExtension.ts +++ b/apps/web/src/t3code-custom/chat/useComposerSkillExtension.ts @@ -1,42 +1,27 @@ -import type { ProviderCommandEntry, ProviderKind } from "@t3tools/contracts"; -import { useCallback, useMemo } from "react"; +import type { ProviderKind, ServerProviderSkill } from "@t3tools/contracts"; +import { useMemo } from "react"; -import type { ComposerCommandItem } from "~/components/chat/ComposerCommandMenu"; import { deriveComposerSkillSelections, toCodexSkillReferencesForSend, } from "~/codexSkillSelections"; -const EMPTY_SKILL_NAMES: ReadonlyArray = Object.freeze([]); -const EMPTY_DISCOVERED_SKILLS: ReadonlyArray = Object.freeze([]); -const EMPTY_SKILL_MENU_ITEMS: ReadonlyArray = Object.freeze([]); +const EMPTY_AVAILABLE_SKILLS: ReadonlyArray = Object.freeze([]); const EMPTY_SELECTED_SKILLS: ReadonlyArray<{ name: string; path: string }> = Object.freeze([]); export function useComposerSkillExtension(input: { selectedProvider: ProviderKind; prompt: string; - discoveredProviderSkills: ReadonlyArray; + availableSkills: ReadonlyArray; }) { - const { selectedProvider, prompt, discoveredProviderSkills } = input; + const { selectedProvider, prompt, availableSkills } = input; - const codexDiscoveredSkills = useMemo( + const codexAvailableSkills = useMemo( () => selectedProvider === "codex" - ? discoveredProviderSkills.filter((entry) => typeof entry.path === "string") - : EMPTY_DISCOVERED_SKILLS, - [discoveredProviderSkills, selectedProvider], - ); - - const skillNames = useMemo( - () => - selectedProvider === "codex" - ? codexDiscoveredSkills.map((entry) => entry.name) - : EMPTY_SKILL_NAMES, - [codexDiscoveredSkills, selectedProvider], - ); - const customTokenTexts = useMemo( - () => skillNames.map((skillName) => `$${skillName}`), - [skillNames], + ? availableSkills.filter((skill) => skill.enabled && typeof skill.path === "string") + : EMPTY_AVAILABLE_SKILLS, + [availableSkills, selectedProvider], ); const skillSelections = useMemo( @@ -44,44 +29,10 @@ export function useComposerSkillExtension(input: { selectedProvider === "codex" ? deriveComposerSkillSelections({ prompt, - availableSkills: codexDiscoveredSkills, + availableSkills: codexAvailableSkills, }) : [], - [codexDiscoveredSkills, prompt, selectedProvider], - ); - - const getSkillMenuItems = useCallback( - (query: string): ComposerCommandItem[] => { - if (selectedProvider !== "codex") { - return [...EMPTY_SKILL_MENU_ITEMS]; - } - const normalizedQuery = query.trim().toLowerCase(); - const filteredSkills = !normalizedQuery - ? codexDiscoveredSkills - : codexDiscoveredSkills.filter((entry) => - `${entry.name} ${entry.description ?? ""} ${entry.path ?? ""}` - .toLowerCase() - .includes(normalizedQuery), - ); - return filteredSkills.flatMap((entry) => - entry.path - ? [ - { - id: `skill:${entry.source}:${entry.path}`, - type: "skill" as const, - provider: selectedProvider, - name: entry.name, - path: entry.path, - label: `$${entry.name}`, - description: - entry.description || - (entry.source === "project" ? entry.path : `${entry.source} · ${entry.path}`), - }, - ] - : [], - ); - }, - [codexDiscoveredSkills, selectedProvider], + [codexAvailableSkills, prompt, selectedProvider], ); const selectedSkills = useMemo( @@ -93,8 +44,6 @@ export function useComposerSkillExtension(input: { ); return { - customTokenTexts, selectedSkills, - getSkillMenuItems, }; } diff --git a/apps/web/src/t3code-custom/hooks/index.ts b/apps/web/src/t3code-custom/hooks/index.ts index 5b16a98a10..5c53608df8 100644 --- a/apps/web/src/t3code-custom/hooks/index.ts +++ b/apps/web/src/t3code-custom/hooks/index.ts @@ -1,3 +1,4 @@ export { useThreadLoopActions } from "./useThreadLoopActions"; export { useComposerPasteFileReference } from "./useComposerPasteFileReference"; export { useComposerFileReferenceSend } from "./useComposerFileReferenceSend"; +export { useComposerSendExtension } from "./useComposerSendExtension"; diff --git a/apps/web/src/t3code-custom/hooks/useComposerSendExtension.ts b/apps/web/src/t3code-custom/hooks/useComposerSendExtension.ts new file mode 100644 index 0000000000..7916783fce --- /dev/null +++ b/apps/web/src/t3code-custom/hooks/useComposerSendExtension.ts @@ -0,0 +1,63 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, type MutableRefObject } from "react"; + +import type { ComposerImageAttachment, DraftId } from "~/composerDraftStore"; +import { appendTerminalContextsToPrompt, type TerminalContextDraft } from "~/lib/terminalContext"; + +import type { ComposerFileReference } from "../file-references"; +import { useComposerFileReferenceSend } from "./useComposerFileReferenceSend"; + +type ComposerDraftTarget = ScopedThreadRef | DraftId; + +export function useComposerSendExtension(input: { + composerDraftTarget: ComposerDraftTarget; + workspaceRoot: string | null | undefined; + promptRef: MutableRefObject; + composerImagesRef: MutableRefObject; + composerFileReferencesRef: MutableRefObject; + composerTerminalContextsRef: MutableRefObject; + setComposerDraftPrompt: (target: ComposerDraftTarget, prompt: string) => void; + addComposerDraftImages: (target: ComposerDraftTarget, images: ComposerImageAttachment[]) => void; + setComposerDraftFileReferences: ( + target: ComposerDraftTarget, + references: ComposerFileReference[], + ) => void; + setComposerDraftTerminalContexts: ( + target: ComposerDraftTarget, + contexts: TerminalContextDraft[], + ) => void; +}) { + const fileReferenceSend = useComposerFileReferenceSend(input); + + const getBlockedSendError = useCallback((isResolvingFileReferences: boolean): string | null => { + return isResolvingFileReferences + ? "Espere as referencias de arquivo terminarem de resolver." + : null; + }, []); + + const buildPlanFollowUpText = useCallback( + (trimmedPrompt: string, fileReferences: ReadonlyArray) => + fileReferenceSend.appendPromptWithFileReferences(trimmedPrompt, fileReferences), + [fileReferenceSend], + ); + + const buildMessageTextForSend = useCallback( + (input: { + prompt: string; + fileReferences: ReadonlyArray; + terminalContexts: ReadonlyArray; + }) => + appendTerminalContextsToPrompt( + fileReferenceSend.appendPromptWithFileReferences(input.prompt, input.fileReferences), + input.terminalContexts, + ), + [fileReferenceSend], + ); + + return { + ...fileReferenceSend, + buildMessageTextForSend, + buildPlanFollowUpText, + getBlockedSendError, + }; +} diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0f3b9129b3..b6d31b57e9 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -6,6 +6,7 @@ import { markThreadUnread, reorderProjects, setProjectExpanded, + setThreadChangedFilesExpanded, syncProjects, syncThreads, type UiState, @@ -16,6 +17,7 @@ function makeUiState(overrides: Partial = {}): UiState { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, ...overrides, }; } @@ -137,6 +139,14 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", [thread2]: "2026-02-25T12:36:00.000Z", }, + threadChangedFilesExpandedById: { + [thread1]: { + "turn-1": false, + }, + [thread2]: { + "turn-2": false, + }, + }, }); const next = syncThreads(initialState, [{ key: thread1 }]); @@ -144,6 +154,11 @@ describe("uiStateStore pure functions", () => { expect(next.threadLastVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", }); + expect(next.threadChangedFilesExpandedById).toEqual({ + [thread1]: { + "turn-1": false, + }, + }); }); it("syncThreads seeds visit state for unseen snapshot threads", () => { @@ -183,10 +198,44 @@ describe("uiStateStore pure functions", () => { threadLastVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", }, + threadChangedFilesExpandedById: { + [thread1]: { + "turn-1": false, + }, + }, }); const next = clearThreadUi(initialState, thread1); expect(next.threadLastVisitedAtById).toEqual({}); + expect(next.threadChangedFilesExpandedById).toEqual({}); + }); + + it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { + const thread1 = ThreadId.make("thread-1"); + const initialState = makeUiState(); + + const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); + + expect(next.threadChangedFilesExpandedById).toEqual({ + [thread1]: { + "turn-1": false, + }, + }); + }); + + it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { + const thread1 = ThreadId.make("thread-1"); + const initialState = makeUiState({ + threadChangedFilesExpandedById: { + [thread1]: { + "turn-1": false, + }, + }, + }); + + const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); + + expect(next.threadChangedFilesExpandedById).toEqual({}); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 5a7d53a028..5f75b60281 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -18,6 +18,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; + threadChangedFilesExpandedById?: Record>; } export interface UiProjectState { @@ -27,6 +28,7 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; + threadChangedFilesExpandedById: Record>; } export interface UiState extends UiProjectState, UiThreadState {} @@ -45,6 +47,7 @@ const initialState: UiState = { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, }; const persistedExpandedProjectCwds = new Set(); @@ -69,13 +72,47 @@ function readPersistedState(): UiState { } return initialState; } - hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState); - return initialState; + const parsed = JSON.parse(raw) as PersistedUiState; + hydratePersistedProjectState(parsed); + return { + ...initialState, + threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( + parsed.threadChangedFilesExpandedById, + ), + }; } catch { return initialState; } } +function sanitizePersistedThreadChangedFilesExpanded( + value: PersistedUiState["threadChangedFilesExpandedById"], +): Record> { + if (!value || typeof value !== "object") { + return {}; + } + + const nextState: Record> = {}; + for (const [threadId, turns] of Object.entries(value)) { + if (!threadId || !turns || typeof turns !== "object") { + continue; + } + + const nextTurns: Record = {}; + for (const [turnId, expanded] of Object.entries(turns)) { + if (turnId && typeof expanded === "boolean" && expanded === false) { + nextTurns[turnId] = false; + } + } + + if (Object.keys(nextTurns).length > 0) { + nextState[threadId] = nextTurns; + } + } + + return nextState; +} + function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; @@ -106,11 +143,20 @@ function persistState(state: UiState): void { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); + const threadChangedFilesExpandedById = Object.fromEntries( + Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { + const nextTurns = Object.fromEntries( + Object.entries(turns).filter(([, expanded]) => expanded === false), + ); + return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; + }), + ); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds, projectOrderCwds, + threadChangedFilesExpandedById, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -146,6 +192,23 @@ function projectOrdersEqual(left: readonly string[], right: readonly string[]): ); } +function nestedBooleanRecordsEqual( + left: Record>, + right: Record>, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + if (leftEntries.length !== rightEntries.length) { + return false; + } + for (const [key, value] of leftEntries) { + if (!(key in right) || !recordsEqual(value, right[key]!)) { + return false; + } + } + return true; +} + export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { const previousProjectCwdById = new Map(currentProjectCwdById); const previousProjectIdByCwd = new Map( @@ -260,12 +323,24 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; } } - if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { + const nextThreadChangedFilesExpandedById = Object.fromEntries( + Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) => + retainedThreadIds.has(threadId), + ), + ); + if ( + recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && + nestedBooleanRecordsEqual( + state.threadChangedFilesExpandedById, + nextThreadChangedFilesExpandedById, + ) + ) { return state; } return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, + threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, }; } @@ -316,14 +391,68 @@ export function markThreadUnread( } export function clearThreadUi(state: UiState, threadId: string): UiState { - if (!(threadId in state.threadLastVisitedAtById)) { + const hasVisitedState = threadId in state.threadLastVisitedAtById; + const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; + if (!hasVisitedState && !hasChangedFilesState) { return state; } const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; + const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; delete nextThreadLastVisitedAtById[threadId]; + delete nextThreadChangedFilesExpandedById[threadId]; return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, + threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, + }; +} + +export function setThreadChangedFilesExpanded( + state: UiState, + threadId: string, + turnId: string, + expanded: boolean, +): UiState { + const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; + const currentExpanded = currentThreadState[turnId] ?? true; + if (currentExpanded === expanded) { + return state; + } + + if (expanded) { + if (!(turnId in currentThreadState)) { + return state; + } + + const nextThreadState = { ...currentThreadState }; + delete nextThreadState[turnId]; + if (Object.keys(nextThreadState).length === 0) { + const nextState = { ...state.threadChangedFilesExpandedById }; + delete nextState[threadId]; + return { + ...state, + threadChangedFilesExpandedById: nextState, + }; + } + + return { + ...state, + threadChangedFilesExpandedById: { + ...state.threadChangedFilesExpandedById, + [threadId]: nextThreadState, + }, + }; + } + + return { + ...state, + threadChangedFilesExpandedById: { + ...state.threadChangedFilesExpandedById, + [threadId]: { + ...currentThreadState, + [turnId]: false, + }, + }, }; } @@ -382,6 +511,7 @@ interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt?: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; + setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: (draggedProjectId: string, targetProjectId: string) => void; @@ -396,6 +526,8 @@ export const useUiStateStore = create((set) => ({ markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), + setThreadChangedFilesExpanded: (threadId, turnId, expanded) => + set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), diff --git a/bun.lock b/bun.lock index f54517fddb..0c95792b69 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.15", + "version": "0.0.17", "dependencies": { "effect": "catalog:", "electron": "40.6.0", @@ -42,7 +42,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.15", + "version": "0.0.17", "bin": { "t3": "./dist/bin.mjs", }, @@ -71,7 +71,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.15", + "version": "0.0.17", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -136,7 +136,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.15", + "version": "0.0.17", "dependencies": { "effect": "catalog:", }, @@ -185,7 +185,13 @@ "node-pty", ], "overrides": { + "@effect/atom-react": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", + "@effect/vitest": "catalog:", + "effect": "catalog:", "vite": "^8.0.0", }, "catalog": { diff --git a/package.json b/package.json index 8fe4a98672..97b30e6d4d 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,13 @@ "vitest": "catalog:" }, "overrides": { + "@effect/atom-react": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", + "@effect/vitest": "catalog:", + "effect": "catalog:", "vite": "^8.0.0" }, "engines": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index fe03c205a5..63ce74a1ab 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.15", + "version": "0.0.17", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts new file mode 100644 index 0000000000..6e5f70c2e4 --- /dev/null +++ b/packages/contracts/src/server.test.ts @@ -0,0 +1,26 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { ServerProvider } from "./server"; + +const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); + +describe("ServerProvider", () => { + it("defaults capability arrays when decoding legacy snapshots", () => { + const parsed = decodeServerProvider({ + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + }); + + expect(parsed.slashCommands).toEqual([]); + expect(parsed.skills).toEqual([]); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a4e33c990b..50db737c6a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Effect, Schema } from "effect"; import { ExecutionEnvironmentDescriptor } from "./environment"; import { ServerAuthDescriptor } from "./auth"; import { @@ -58,6 +58,29 @@ export const ServerProviderModel = Schema.Struct({ }); export type ServerProviderModel = typeof ServerProviderModel.Type; +export const ServerProviderSlashCommandInput = Schema.Struct({ + hint: TrimmedNonEmptyString, +}); +export type ServerProviderSlashCommandInput = typeof ServerProviderSlashCommandInput.Type; + +export const ServerProviderSlashCommand = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + input: Schema.optional(ServerProviderSlashCommandInput), +}); +export type ServerProviderSlashCommand = typeof ServerProviderSlashCommand.Type; + +export const ServerProviderSkill = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + path: TrimmedNonEmptyString, + scope: Schema.optional(TrimmedNonEmptyString), + enabled: Schema.Boolean, + displayName: Schema.optional(TrimmedNonEmptyString), + shortDescription: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderSkill = typeof ServerProviderSkill.Type; + export const ServerProvider = Schema.Struct({ provider: ProviderKind, enabled: Schema.Boolean, @@ -68,6 +91,10 @@ export const ServerProvider = Schema.Struct({ checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }); export type ServerProvider = typeof ServerProvider.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index bc103dab71..ed65cbeaf3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -52,6 +52,10 @@ "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" }, + "./searchRanking": { + "types": "./src/searchRanking.ts", + "import": "./src/searchRanking.ts" + }, "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts new file mode 100644 index 0000000000..d8c4b3d6ca --- /dev/null +++ b/packages/shared/src/searchRanking.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { + compareRankedSearchResults, + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, + scoreSubsequenceMatch, +} from "./searchRanking"; + +describe("normalizeSearchQuery", () => { + it("trims and lowercases queries", () => { + expect(normalizeSearchQuery(" UI ")).toBe("ui"); + }); + + it("can strip leading trigger characters", () => { + expect(normalizeSearchQuery(" $ui", { trimLeadingPattern: /^\$+/ })).toBe("ui"); + }); +}); + +describe("scoreQueryMatch", () => { + it("prefers exact matches over broader contains matches", () => { + expect( + scoreQueryMatch({ + value: "ui", + query: "ui", + exactBase: 0, + prefixBase: 10, + includesBase: 20, + }), + ).toBe(0); + + expect( + scoreQueryMatch({ + value: "building native ui", + query: "ui", + exactBase: 0, + prefixBase: 10, + boundaryBase: 20, + includesBase: 30, + }), + ).toBeGreaterThan(0); + }); + + it("treats boundary matches as stronger than generic contains matches", () => { + const boundaryScore = scoreQueryMatch({ + value: "gh-fix-ci", + query: "fix", + exactBase: 0, + prefixBase: 10, + boundaryBase: 20, + includesBase: 30, + boundaryMarkers: ["-"], + }); + const containsScore = scoreQueryMatch({ + value: "highfixci", + query: "fix", + exactBase: 0, + prefixBase: 10, + boundaryBase: 20, + includesBase: 30, + boundaryMarkers: ["-"], + }); + + expect(boundaryScore).not.toBeNull(); + expect(containsScore).not.toBeNull(); + expect(boundaryScore!).toBeLessThan(containsScore!); + }); +}); + +describe("scoreSubsequenceMatch", () => { + it("scores tighter subsequences ahead of looser ones", () => { + const compact = scoreSubsequenceMatch("ghfixci", "gfc"); + const spread = scoreSubsequenceMatch("github-fix-ci", "gfc"); + + expect(compact).not.toBeNull(); + expect(spread).not.toBeNull(); + expect(compact!).toBeLessThan(spread!); + }); +}); + +describe("insertRankedSearchResult", () => { + it("keeps the best-ranked candidates within the limit", () => { + const ranked = [ + { item: "b", score: 20, tieBreaker: "b" }, + { item: "d", score: 40, tieBreaker: "d" }, + ]; + + insertRankedSearchResult(ranked, { item: "a", score: 10, tieBreaker: "a" }, 2); + insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2); + + expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]); + expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0); + }); +}); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts new file mode 100644 index 0000000000..b2fb2e223d --- /dev/null +++ b/packages/shared/src/searchRanking.ts @@ -0,0 +1,192 @@ +export type RankedSearchResult = { + item: T; + score: number; + tieBreaker: string; +}; + +export function normalizeSearchQuery( + input: string, + options?: { + trimLeadingPattern?: RegExp; + }, +): string { + const trimmed = input.trim(); + if (!trimmed) { + return ""; + } + return options?.trimLeadingPattern + ? trimmed.replace(options.trimLeadingPattern, "").toLowerCase() + : trimmed.toLowerCase(); +} + +export function scoreSubsequenceMatch(value: string, query: string): number | null { + if (!query) return 0; + + let queryIndex = 0; + let firstMatchIndex = -1; + let previousMatchIndex = -1; + let gapPenalty = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) { + continue; + } + + if (firstMatchIndex === -1) { + firstMatchIndex = valueIndex; + } + if (previousMatchIndex !== -1) { + gapPenalty += valueIndex - previousMatchIndex - 1; + } + + previousMatchIndex = valueIndex; + queryIndex += 1; + if (queryIndex === query.length) { + const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; + const lengthPenalty = Math.min(64, value.length - query.length); + return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; + } + } + + return null; +} + +function lengthPenalty(value: string, query: string): number { + return Math.min(64, Math.max(0, value.length - query.length)); +} + +function findBoundaryMatchIndex( + value: string, + query: string, + boundaryMarkers: readonly string[], +): number | null { + let bestIndex: number | null = null; + + for (const marker of boundaryMarkers) { + const index = value.indexOf(`${marker}${query}`); + if (index === -1) { + continue; + } + + const matchIndex = index + marker.length; + if (bestIndex === null || matchIndex < bestIndex) { + bestIndex = matchIndex; + } + } + + return bestIndex; +} + +/** + * Scores how well `value` matches `query` using tiered match strategies. + * + * **Expects pre-normalized inputs**: both `value` and `query` must already be + * trimmed and lowercased (e.g. via {@link normalizeSearchQuery}). + */ +export function scoreQueryMatch(input: { + value: string; + query: string; + exactBase: number; + prefixBase?: number; + boundaryBase?: number; + includesBase?: number; + fuzzyBase?: number; + boundaryMarkers?: readonly string[]; +}): number | null { + const { value, query } = input; + + if (!value || !query) { + return null; + } + + if (value === query) { + return input.exactBase; + } + + if (input.prefixBase !== undefined && value.startsWith(query)) { + return input.prefixBase + lengthPenalty(value, query); + } + + if (input.boundaryBase !== undefined) { + const boundaryIndex = findBoundaryMatchIndex( + value, + query, + input.boundaryMarkers ?? [" ", "-", "_", "/"], + ); + if (boundaryIndex !== null) { + return input.boundaryBase + boundaryIndex * 2 + lengthPenalty(value, query); + } + } + + if (input.includesBase !== undefined) { + const includesIndex = value.indexOf(query); + if (includesIndex !== -1) { + return input.includesBase + includesIndex * 2 + lengthPenalty(value, query); + } + } + + if (input.fuzzyBase !== undefined) { + const fuzzyScore = scoreSubsequenceMatch(value, query); + if (fuzzyScore !== null) { + return input.fuzzyBase + fuzzyScore; + } + } + + return null; +} + +export function compareRankedSearchResults( + left: RankedSearchResult, + right: RankedSearchResult, +): number { + const scoreDelta = left.score - right.score; + if (scoreDelta !== 0) return scoreDelta; + return left.tieBreaker.localeCompare(right.tieBreaker); +} + +function findInsertionIndex( + rankedEntries: RankedSearchResult[], + candidate: RankedSearchResult, +): number { + let low = 0; + let high = rankedEntries.length; + + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const current = rankedEntries[middle]; + if (!current) { + break; + } + + if (compareRankedSearchResults(candidate, current) < 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +export function insertRankedSearchResult( + rankedEntries: RankedSearchResult[], + candidate: RankedSearchResult, + limit: number, +): void { + if (limit <= 0) { + return; + } + + const insertionIndex = findInsertionIndex(rankedEntries, candidate); + if (rankedEntries.length < limit) { + rankedEntries.splice(insertionIndex, 0, candidate); + return; + } + + if (insertionIndex >= limit) { + return; + } + + rankedEntries.splice(insertionIndex, 0, candidate); + rankedEntries.pop(); +} diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index dfeb797b33..97d46451ae 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -427,9 +427,9 @@ function validateBundledClientAssets(clientDir: string) { } function resolveDesktopRuntimeDependencies( - dependencies: Record | undefined, - catalog: Record, -): Record { + dependencies: Record | undefined, + catalog: Record, +): Record { if (!dependencies || Object.keys(dependencies).length === 0) { return {}; } diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 1a15ccb8bc..b880f1bca4 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; import { + checkPortAvailabilityOnHosts, createDevRunnerEnv, findFirstAvailableOffset, resolveModePortOffsets, @@ -164,11 +165,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { const env = yield* createDevRunnerEnv({ mode: "dev:desktop", baseEnv: { - T3CODE_PORT: "3773", + T3CODE_PORT: "13773", T3CODE_MODE: "web", T3CODE_NO_BROWSER: "0", T3CODE_HOST: "0.0.0.0", - VITE_WS_URL: "ws://localhost:3773", + VITE_WS_URL: "ws://localhost:13773", }, serverOffset: 0, webOffset: 0, @@ -193,6 +194,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); }), ); + + it.effect("defaults dev server mode to the higher backend port range", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_PORT, "13773"); + assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); + assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); + }), + ); }); describe("findFirstAvailableOffset", () => { @@ -211,7 +234,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("advances until all required ports are available", () => Effect.gen(function* () { - const taken = new Set([3773, 5733, 3774, 5734]); + const taken = new Set([13773, 5733, 13774, 5734]); const offset = yield* findFirstAvailableOffset({ startOffset: 0, requireServerPort: true, @@ -223,16 +246,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - it.effect("allows offsets where only non-required ports exceed max", () => + it.effect("allows offsets where the non-required server port exceeds max", () => Effect.gen(function* () { const offset = yield* findFirstAvailableOffset({ - startOffset: 59_803, - requireServerPort: true, - requireWebPort: false, + startOffset: 59_802, + requireServerPort: false, + requireWebPort: true, checkPortAvailability: () => Effect.succeed(true), }); - assert.equal(offset, 59_803); + assert.equal(offset, 59_802); + }), + ); + }); + + describe("checkPortAvailabilityOnHosts", () => { + it.effect("checks overlapping hosts sequentially to avoid self-interference", () => + Effect.gen(function* () { + let inFlightCount = 0; + const calls: Array<[number, string]> = []; + + const available = yield* checkPortAvailabilityOnHosts( + 13_773, + ["127.0.0.1", "0.0.0.0", "::"], + (port, host) => + Effect.promise(async () => { + calls.push([port, host]); + inFlightCount += 1; + const overlapped = inFlightCount > 1; + await Promise.resolve(); + inFlightCount -= 1; + return !overlapped; + }), + ); + + assert.equal(available, true); + assert.deepStrictEqual(calls, [ + [13_773, "127.0.0.1"], + [13_773, "0.0.0.0"], + [13_773, "::"], + ]); }), ); }); @@ -240,7 +293,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("resolveModePortOffsets", () => { it.effect("uses a shared fallback offset for dev mode", () => Effect.gen(function* () { - const taken = new Set([3773, 5733]); + const taken = new Set([13773, 5733]); const offsets = yield* resolveModePortOffsets({ mode: "dev", startOffset: 0, @@ -270,7 +323,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("shifts only server offset for dev:server", () => Effect.gen(function* () { - const taken = new Set([3773]); + const taken = new Set([13773]); const offsets = yield* resolveModePortOffsets({ mode: "dev:server", startOffset: 0, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index b0bbe51e68..4d34fe389e 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; -const BASE_SERVER_PORT = 3773; +const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; +const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(homedir(), ".t3"), @@ -221,10 +222,28 @@ function portPairForOffset(offset: number): { }; } +export function checkPortAvailabilityOnHosts( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + for (const host of hosts) { + if (!(yield* canListenOnHost(port, host))) { + return false; + } + } + + return true; + }); +} + const defaultCheckPortAvailability: PortAvailabilityCheck = (port) => Effect.gen(function* () { const net = yield* NetService; - return yield* net.isPortAvailableOnLoopback(port); + return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) => + net.canListenOnHost(candidatePort, host), + ); }); interface FindFirstAvailableOffsetInput { diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts index 2946c4a5d9..597bd06c24 100644 --- a/scripts/lib/resolve-catalog.ts +++ b/scripts/lib/resolve-catalog.ts @@ -5,10 +5,10 @@ * the concrete version string found in `catalog`. Throws on missing entries. */ export function resolveCatalogDependencies( - dependencies: Record, - catalog: Record, + dependencies: Record, + catalog: Record, label: string, -): Record { +): Record { return Object.fromEntries( Object.entries(dependencies).map(([name, spec]) => { if (typeof spec !== "string" || !spec.startsWith("catalog:")) {