diff --git a/.github/workflows/production-source-guard.yml b/.github/workflows/production-source-guard.yml new file mode 100644 index 000000000..7c057295f --- /dev/null +++ b/.github/workflows/production-source-guard.yml @@ -0,0 +1,18 @@ +name: Production Source Guard + +on: + pull_request: + branches: + - production + +jobs: + require-main-source: + name: Require main as PR source + runs-on: ubuntu-latest + steps: + - name: Validate source branch + run: | + if [ "${{ github.head_ref }}" != "main" ]; then + echo "Production promotions must come from main; received '${{ github.head_ref }}'." >&2 + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index ac24c6a2c..f30c0c253 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,14 @@ - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. - NEVER run `bun test`. Always use `bun run test` (runs Vitest). +## Bun Gotcha + +- In this environment, `bun` may not be on `PATH` even though Bun is installed at `/home/claude/.bun/bin/bun`. +- If plain `bun ...` fails with `bun: command not found`, use the absolute binary path instead. +- For `bun typecheck`, also prepend Bun to `PATH` so Turbo can find the package manager binary: + `env PATH="/home/claude/.bun/bin:$PATH" /home/claude/.bun/bin/bun typecheck` +- `bun fmt` and `bun lint` can be run directly with `/home/claude/.bun/bin/bun fmt` and `/home/claude/.bun/bin/bun lint`. + ## Project Snapshot T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon). diff --git a/apps/server/package.json b/apps/server/package.json index 546a2c3b6..17e8ca2f0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", + "web-push": "^3.6.7", "ws": "^8.18.0" }, "devDependencies": { @@ -38,6 +39,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/web-push": "^3.6.4", "@types/ws": "^8.5.13", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469..9ba2d32fc 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -28,6 +28,9 @@ export interface ServerConfigShape { readonly authToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; + readonly webPushVapidPublicKey: string | undefined; + readonly webPushVapidPrivateKey: string | undefined; + readonly webPushSubject: string | undefined; } /** @@ -54,6 +57,9 @@ export class ServerConfig extends ServiceMap.Service = { T3CODE_NO_BROWSER: "true" }, ) => { const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const envLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_STATE_DIR: uniqueStateDir, + ...env, + }, + }), + ); return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - T3CODE_STATE_DIR: uniqueStateDir, - ...env, - }, - }), - ), - ), + Effect.provide(Layer.mergeAll(testLayer, envLayer)), ); }; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cb..d60de96e8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -40,6 +40,9 @@ interface CliInput { readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; + readonly webPushVapidPublicKey: Option.Option; + readonly webPushVapidPrivateKey: Option.Option; + readonly webPushSubject: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; } @@ -112,6 +115,18 @@ const CliEnvConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + webPushVapidPublicKey: Config.string("T3CODE_WEB_PUSH_VAPID_PUBLIC_KEY").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + webPushVapidPrivateKey: Config.string("T3CODE_WEB_PUSH_VAPID_PRIVATE_KEY").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + webPushSubject: Config.string("T3CODE_WEB_PUSH_SUBJECT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -158,6 +173,11 @@ const ServerConfigLive = (input: CliInput) => const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; + const webPushVapidPublicKey = + Option.getOrUndefined(input.webPushVapidPublicKey) ?? env.webPushVapidPublicKey; + const webPushVapidPrivateKey = + Option.getOrUndefined(input.webPushVapidPrivateKey) ?? env.webPushVapidPrivateKey; + const webPushSubject = Option.getOrUndefined(input.webPushSubject) ?? env.webPushSubject; const autoBootstrapProjectFromCwd = resolveBooleanFlag( input.autoBootstrapProjectFromCwd, env.autoBootstrapProjectFromCwd ?? mode === "web", @@ -185,6 +205,9 @@ const ServerConfigLive = (input: CliInput) => devUrl, noBrowser, authToken, + webPushVapidPublicKey, + webPushVapidPrivateKey, + webPushSubject, autoBootstrapProjectFromCwd, logWebSocketEvents, } satisfies ServerConfigShape; @@ -243,6 +266,22 @@ const makeServerProgram = (input: CliInput) => yield* cliConfig.fixPath; const config = yield* ServerConfig; + const configuredWebPushFieldCount = [ + config.webPushVapidPublicKey, + config.webPushVapidPrivateKey, + config.webPushSubject, + ].filter((value) => typeof value === "string" && value.length > 0).length; + + if (configuredWebPushFieldCount > 0 && configuredWebPushFieldCount < 3) { + yield* Effect.logWarning( + "web push configuration is incomplete; push notifications disabled", + { + hasPublicKey: Boolean(config.webPushVapidPublicKey), + hasPrivateKey: Boolean(config.webPushVapidPrivateKey), + hasSubject: Boolean(config.webPushSubject), + }, + ); + } if (!config.devUrl && !config.staticDir) { yield* Effect.logWarning( @@ -261,11 +300,19 @@ const makeServerProgram = (input: CliInput) => config.host && !isWildcardHost(config.host) ? `http://${formatHostForUrl(config.host)}:${config.port}` : localUrl; - const { authToken, devUrl, ...safeConfig } = config; + const { + authToken, + devUrl, + webPushVapidPublicKey: _webPushVapidPublicKey, + webPushVapidPrivateKey: _webPushVapidPrivateKey, + webPushSubject: _webPushSubject, + ...safeConfig + } = config; yield* Effect.logInfo("T3 Code running", { ...safeConfig, devUrl: devUrl?.toString(), authEnabled: Boolean(authToken), + webPushEnabled: configuredWebPushFieldCount === 3, }); if (!config.noBrowser) { @@ -317,6 +364,18 @@ const authTokenFlag = Flag.string("auth-token").pipe( Flag.withAlias("token"), Flag.optional, ); +const webPushVapidPublicKeyFlag = Flag.string("web-push-vapid-public-key").pipe( + Flag.withDescription("VAPID public key used for Web Push."), + Flag.optional, +); +const webPushVapidPrivateKeyFlag = Flag.string("web-push-vapid-private-key").pipe( + Flag.withDescription("VAPID private key used for Web Push."), + Flag.optional, +); +const webPushSubjectFlag = Flag.string("web-push-subject").pipe( + Flag.withDescription("VAPID subject used for Web Push."), + Flag.optional, +); const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Create a project for the current working directory on startup when missing.", @@ -339,6 +398,9 @@ export const t3Cli = Command.make("t3", { devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, + webPushVapidPublicKey: webPushVapidPublicKeyFlag, + webPushVapidPrivateKey: webPushVapidPrivateKeyFlag, + webPushSubject: webPushSubjectFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, }).pipe( diff --git a/apps/server/src/notifications/Layers/WebPushNotifications.ts b/apps/server/src/notifications/Layers/WebPushNotifications.ts new file mode 100644 index 000000000..581199cca --- /dev/null +++ b/apps/server/src/notifications/Layers/WebPushNotifications.ts @@ -0,0 +1,224 @@ +import { createRequire } from "node:module"; + +import { Effect, Layer, Schema } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + WebPushNotifications, + type WebPushNotificationsShape, +} from "../Services/WebPushNotifications.ts"; +import { WebPushSubscriptionRepository } from "../Services/WebPushSubscriptionRepository.ts"; +import { notificationIntentFromEvent } from "../policy.ts"; +import { type WebPushConfigShape, WebPushRequestError } from "../types.ts"; +import type { PushSubscription, SendResult } from "web-push"; + +const require = createRequire(import.meta.url); +const webPush = require("web-push") as typeof import("web-push"); +const TRANSIENT_DELIVERY_ERROR_MAX_LENGTH = 512; + +class WebPushDeliveryError extends Schema.TaggedErrorClass()( + "WebPushDeliveryError", + { + message: Schema.String, + }, +) {} + +function summarizeDeliveryError(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.slice(0, TRANSIENT_DELIVERY_ERROR_MAX_LENGTH); + } + return "Unknown web push delivery error"; +} + +function isPermanentDeliveryError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "statusCode" in error && + ((error as { statusCode?: unknown }).statusCode === 404 || + (error as { statusCode?: unknown }).statusCode === 410) + ); +} + +const makeWebPushNotifications = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const repository = yield* WebPushSubscriptionRepository; + + const hasFullConfig = + typeof serverConfig.webPushVapidPublicKey === "string" && + serverConfig.webPushVapidPublicKey.length > 0 && + typeof serverConfig.webPushVapidPrivateKey === "string" && + serverConfig.webPushVapidPrivateKey.length > 0 && + typeof serverConfig.webPushSubject === "string" && + serverConfig.webPushSubject.length > 0; + + const config: WebPushConfigShape = hasFullConfig + ? { + enabled: true, + publicKey: serverConfig.webPushVapidPublicKey ?? null, + subject: serverConfig.webPushSubject ?? null, + } + : { + enabled: false, + publicKey: null, + subject: null, + }; + + if (config.enabled) { + webPush.setVapidDetails( + serverConfig.webPushSubject!, + serverConfig.webPushVapidPublicKey!, + serverConfig.webPushVapidPrivateKey!, + ); + } + + const ensureEnabled = () => + config.enabled + ? Effect.void + : Effect.fail( + new WebPushRequestError({ + message: "Web push notifications are not configured on this server.", + }), + ); + + const subscribe: WebPushNotificationsShape["subscribe"] = (input) => + Effect.gen(function* () { + yield* ensureEnabled(); + const nowIso = new Date().toISOString(); + yield* repository + .upsert({ + endpoint: input.subscription.endpoint, + subscriptionJson: JSON.stringify(input.subscription), + userAgent: input.userAgent, + appVersion: input.appVersion, + nowIso, + }) + .pipe( + Effect.mapError( + () => + new WebPushRequestError({ + message: "Failed to store the push subscription.", + }), + ), + ); + }); + + const unsubscribe: WebPushNotificationsShape["unsubscribe"] = (input) => + Effect.gen(function* () { + yield* ensureEnabled(); + yield* repository + .deleteByEndpoint({ + endpoint: input.subscription.endpoint, + }) + .pipe( + Effect.mapError( + () => + new WebPushRequestError({ + message: "Failed to remove the push subscription.", + }), + ), + ); + }); + + const notifySubscription = (input: { + readonly subscription: PushSubscription; + readonly payload: string; + }) => + Effect.tryPromise({ + try: () => webPush.sendNotification(input.subscription, input.payload), + catch: (error) => + new WebPushDeliveryError({ + message: error instanceof Error ? error.message : String(error), + }), + }); + + const notifyEvent: WebPushNotificationsShape["notifyEvent"] = (event) => + Effect.gen(function* () { + if (!config.enabled) { + return; + } + + const snapshot = yield* projectionSnapshotQuery + .getSnapshot() + .pipe(Effect.catch(() => Effect.succeed(null))); + if (snapshot === null) { + return; + } + + const intent = notificationIntentFromEvent({ event, snapshot }); + if (intent === null) { + return; + } + + const subscriptions = yield* repository + .listEnabled() + .pipe(Effect.catch(() => Effect.succeed([]))); + if (subscriptions.length === 0) { + return; + } + + const payload = JSON.stringify(intent); + yield* Effect.forEach( + subscriptions, + (subscriptionRecord) => + Effect.gen(function* () { + const subscription = JSON.parse( + subscriptionRecord.subscriptionJson, + ) as PushSubscription; + + const nowIso = new Date().toISOString(); + const delivered = yield* notifySubscription({ + subscription, + payload, + }).pipe( + Effect.as(true as const), + Effect.catch((error) => + isPermanentDeliveryError(error) + ? repository.deleteByEndpoint({ endpoint: subscription.endpoint }).pipe( + Effect.catch(() => Effect.void), + Effect.as(false as const), + ) + : repository + .markFailure({ + endpoint: subscription.endpoint, + nowIso, + error: summarizeDeliveryError(error), + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.as(false as const), + ), + ), + ); + if (!delivered) { + return; + } + yield* repository + .markDelivered({ + endpoint: subscription.endpoint, + nowIso, + }) + .pipe(Effect.catch(() => Effect.void)); + }), + { concurrency: 4, discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to process web push notification event", { cause }), + ), + ); + + return { + config, + subscribe, + unsubscribe, + notifyEvent, + } satisfies WebPushNotificationsShape; +}); + +export const WebPushNotificationsLive = Layer.effect( + WebPushNotifications, + makeWebPushNotifications, +); diff --git a/apps/server/src/notifications/Layers/WebPushSubscriptionRepository.ts b/apps/server/src/notifications/Layers/WebPushSubscriptionRepository.ts new file mode 100644 index 000000000..133ab8653 --- /dev/null +++ b/apps/server/src/notifications/Layers/WebPushSubscriptionRepository.ts @@ -0,0 +1,230 @@ +import { createHash } from "node:crypto"; + +import { Effect, Layer, Schema } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../../persistence/Errors.ts"; +import { + WebPushSubscriptionRepository, + type WebPushSubscriptionRepositoryShape, +} from "../Services/WebPushSubscriptionRepository.ts"; + +const WebPushSubscriptionDbRow = Schema.Struct({ + subscriptionId: Schema.String, + endpoint: Schema.String, + subscriptionJson: Schema.String, + userAgent: Schema.NullOr(Schema.String), + appVersion: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + lastSeenAt: Schema.String, + lastDeliveredAt: Schema.NullOr(Schema.String), + lastFailureAt: Schema.NullOr(Schema.String), + lastError: Schema.NullOr(Schema.String), + failureCount: Schema.Number, + enabledFlag: Schema.Number, +}); + +const UpsertWebPushSubscriptionInput = Schema.Struct({ + endpoint: Schema.String, + subscriptionJson: Schema.String, + userAgent: Schema.NullOr(Schema.String), + appVersion: Schema.NullOr(Schema.String), + nowIso: Schema.String, +}); + +const UpdateWebPushSubscriptionStatusInput = Schema.Struct({ + endpoint: Schema.String, + nowIso: Schema.String, +}); + +const MarkWebPushSubscriptionFailureInput = Schema.Struct({ + endpoint: Schema.String, + nowIso: Schema.String, + error: Schema.String, +}); + +const DeleteWebPushSubscriptionInput = Schema.Struct({ + endpoint: Schema.String, +}); + +const makeRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: UpsertWebPushSubscriptionInput, + execute: ({ endpoint, subscriptionJson, userAgent, appVersion, nowIso }) => { + const subscriptionId = createHash("sha256").update(endpoint).digest("hex"); + return sql` + INSERT INTO web_push_subscriptions ( + subscription_id, + endpoint, + subscription_json, + user_agent, + app_version, + created_at, + updated_at, + last_seen_at, + last_delivered_at, + last_failure_at, + last_error, + failure_count, + enabled + ) + VALUES ( + ${subscriptionId}, + ${endpoint}, + ${subscriptionJson}, + ${userAgent}, + ${appVersion}, + ${nowIso}, + ${nowIso}, + ${nowIso}, + NULL, + NULL, + NULL, + 0, + 1 + ) + ON CONFLICT (endpoint) + DO UPDATE SET + subscription_json = excluded.subscription_json, + user_agent = excluded.user_agent, + app_version = excluded.app_version, + updated_at = excluded.updated_at, + last_seen_at = excluded.last_seen_at, + last_error = NULL, + last_failure_at = NULL, + failure_count = 0, + enabled = 1 + `; + }, + }); + + const deleteRow = SqlSchema.void({ + Request: DeleteWebPushSubscriptionInput, + execute: ({ endpoint }) => + sql` + DELETE FROM web_push_subscriptions + WHERE endpoint = ${endpoint} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: WebPushSubscriptionDbRow, + execute: () => + sql` + SELECT + subscription_id AS "subscriptionId", + endpoint, + subscription_json AS "subscriptionJson", + user_agent AS "userAgent", + app_version AS "appVersion", + created_at AS "createdAt", + updated_at AS "updatedAt", + last_seen_at AS "lastSeenAt", + last_delivered_at AS "lastDeliveredAt", + last_failure_at AS "lastFailureAt", + last_error AS "lastError", + failure_count AS "failureCount", + enabled AS "enabledFlag" + FROM web_push_subscriptions + WHERE enabled = 1 + ORDER BY updated_at ASC, endpoint ASC + `, + }); + + const markDeliveredRow = SqlSchema.void({ + Request: UpdateWebPushSubscriptionStatusInput, + execute: ({ endpoint, nowIso }) => + sql` + UPDATE web_push_subscriptions + SET + last_delivered_at = ${nowIso}, + updated_at = ${nowIso}, + last_seen_at = ${nowIso}, + last_failure_at = NULL, + last_error = NULL, + failure_count = 0, + enabled = 1 + WHERE endpoint = ${endpoint} + `, + }); + + const markFailureRow = SqlSchema.void({ + Request: MarkWebPushSubscriptionFailureInput, + execute: ({ endpoint, nowIso, error }) => + sql` + UPDATE web_push_subscriptions + SET + updated_at = ${nowIso}, + last_failure_at = ${nowIso}, + last_error = ${error}, + failure_count = failure_count + 1 + WHERE endpoint = ${endpoint} + `, + }); + + const upsert: WebPushSubscriptionRepositoryShape["upsert"] = (input) => + upsertRow({ + endpoint: input.endpoint, + subscriptionJson: input.subscriptionJson, + userAgent: input.userAgent, + appVersion: input.appVersion, + nowIso: input.nowIso, + }).pipe(Effect.mapError(toPersistenceSqlError("WebPushSubscriptionRepository.upsert:query"))); + + const deleteByEndpoint: WebPushSubscriptionRepositoryShape["deleteByEndpoint"] = (input) => + deleteRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("WebPushSubscriptionRepository.deleteByEndpoint:query"), + ), + ); + + const listEnabled: WebPushSubscriptionRepositoryShape["listEnabled"] = () => + listRows(undefined).pipe( + Effect.mapError(toPersistenceSqlError("WebPushSubscriptionRepository.listEnabled:query")), + Effect.map((rows) => + rows.map((row) => ({ + subscriptionId: row.subscriptionId, + endpoint: row.endpoint, + subscriptionJson: row.subscriptionJson, + userAgent: row.userAgent, + appVersion: row.appVersion, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + lastSeenAt: row.lastSeenAt, + lastDeliveredAt: row.lastDeliveredAt, + lastFailureAt: row.lastFailureAt, + lastError: row.lastError, + failureCount: row.failureCount, + enabled: row.enabledFlag === 1, + })), + ), + ); + + const markDelivered: WebPushSubscriptionRepositoryShape["markDelivered"] = (input) => + markDeliveredRow(input).pipe( + Effect.mapError(toPersistenceSqlError("WebPushSubscriptionRepository.markDelivered:query")), + ); + + const markFailure: WebPushSubscriptionRepositoryShape["markFailure"] = (input) => + markFailureRow(input).pipe( + Effect.mapError(toPersistenceSqlError("WebPushSubscriptionRepository.markFailure:query")), + ); + + return { + upsert, + deleteByEndpoint, + listEnabled, + markDelivered, + markFailure, + } satisfies WebPushSubscriptionRepositoryShape; +}); + +export const WebPushSubscriptionRepositoryLive = Layer.effect( + WebPushSubscriptionRepository, + makeRepository, +); diff --git a/apps/server/src/notifications/Services/WebPushNotifications.ts b/apps/server/src/notifications/Services/WebPushNotifications.ts new file mode 100644 index 000000000..dcb980315 --- /dev/null +++ b/apps/server/src/notifications/Services/WebPushNotifications.ts @@ -0,0 +1,23 @@ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { OrchestrationEvent } from "@t3tools/contracts"; + +import type { WebPushConfigShape, WebPushRequestError, WebPushSubscriptionJson } from "../types.ts"; + +export interface WebPushNotificationsShape { + readonly config: WebPushConfigShape; + readonly subscribe: (input: { + readonly subscription: WebPushSubscriptionJson; + readonly userAgent: string | null; + readonly appVersion: string | null; + }) => Effect.Effect; + readonly unsubscribe: (input: { + readonly subscription: Pick; + }) => Effect.Effect; + readonly notifyEvent: (event: OrchestrationEvent) => Effect.Effect; +} + +export class WebPushNotifications extends ServiceMap.Service< + WebPushNotifications, + WebPushNotificationsShape +>()("t3/notifications/Services/WebPushNotifications") {} diff --git a/apps/server/src/notifications/Services/WebPushSubscriptionRepository.ts b/apps/server/src/notifications/Services/WebPushSubscriptionRepository.ts new file mode 100644 index 000000000..e595b8b93 --- /dev/null +++ b/apps/server/src/notifications/Services/WebPushSubscriptionRepository.ts @@ -0,0 +1,36 @@ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import type { WebPushSubscriptionRecord } from "../types.ts"; + +export interface WebPushSubscriptionRepositoryShape { + readonly upsert: (input: { + readonly endpoint: string; + readonly subscriptionJson: string; + readonly userAgent: string | null; + readonly appVersion: string | null; + readonly nowIso: string; + }) => Effect.Effect; + readonly deleteByEndpoint: (input: { + readonly endpoint: string; + }) => Effect.Effect; + readonly listEnabled: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + readonly markDelivered: (input: { + readonly endpoint: string; + readonly nowIso: string; + }) => Effect.Effect; + readonly markFailure: (input: { + readonly endpoint: string; + readonly nowIso: string; + readonly error: string; + }) => Effect.Effect; +} + +export class WebPushSubscriptionRepository extends ServiceMap.Service< + WebPushSubscriptionRepository, + WebPushSubscriptionRepositoryShape +>()("t3/notifications/Services/WebPushSubscriptionRepository") {} diff --git a/apps/server/src/notifications/http.ts b/apps/server/src/notifications/http.ts new file mode 100644 index 000000000..45d41c629 --- /dev/null +++ b/apps/server/src/notifications/http.ts @@ -0,0 +1,132 @@ +import type http from "node:http"; + +import { Result } from "effect"; +import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +import { + DeleteWebPushSubscriptionRequest, + PutWebPushSubscriptionRequest, + type DeleteWebPushSubscriptionRequest as DeleteWebPushSubscriptionRequestBody, + type WebPushConfigResponse, + type PutWebPushSubscriptionRequest as PutWebPushSubscriptionRequestBody, +} from "./types.ts"; + +export const WEB_PUSH_CONFIG_PATH = "/api/web-push/config"; +export const WEB_PUSH_SUBSCRIPTION_PATH = "/api/web-push/subscription"; +export const WEB_PUSH_SERVICE_WORKER_PATH = "/service-worker.js"; +export const WEB_PUSH_MANIFEST_PATH = "/manifest.webmanifest"; + +const decodePutSubscriptionRequest = decodeUnknownJsonResult(PutWebPushSubscriptionRequest); +const decodeDeleteSubscriptionRequest = decodeUnknownJsonResult(DeleteWebPushSubscriptionRequest); + +export function isWebPushConfigRequest(method: string | undefined, pathname: string): boolean { + return method === "GET" && pathname === WEB_PUSH_CONFIG_PATH; +} + +export function isWebPushSubscribeRequest(method: string | undefined, pathname: string): boolean { + return method === "PUT" && pathname === WEB_PUSH_SUBSCRIPTION_PATH; +} + +export function isWebPushUnsubscribeRequest(method: string | undefined, pathname: string): boolean { + return method === "DELETE" && pathname === WEB_PUSH_SUBSCRIPTION_PATH; +} + +export async function readJsonRequestBody(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + const body = Buffer.concat(chunks).toString("utf8"); + return body.length === 0 ? null : JSON.parse(body); +} + +export function decodePutSubscriptionBody( + input: unknown, +): PutWebPushSubscriptionRequestBody | Error { + const decoded = decodePutSubscriptionRequest(input); + return Result.isFailure(decoded) + ? new Error(String(formatSchemaError(decoded.failure as never))) + : decoded.success; +} + +export function decodeDeleteSubscriptionBody( + input: unknown, +): DeleteWebPushSubscriptionRequestBody | Error { + const decoded = decodeDeleteSubscriptionRequest(input); + return Result.isFailure(decoded) + ? new Error(String(formatSchemaError(decoded.failure as never))) + : decoded.success; +} + +export function hasJsonContentType(request: http.IncomingMessage): boolean { + const contentType = request.headers["content-type"]; + if (typeof contentType !== "string") { + return false; + } + return contentType.toLowerCase().includes("application/json"); +} + +export function resolveRequestOrigin(request: http.IncomingMessage): string | null { + const forwardedHost = request.headers["x-forwarded-host"]; + const host = + typeof forwardedHost === "string" + ? forwardedHost + : Array.isArray(forwardedHost) + ? (forwardedHost[0] ?? request.headers.host) + : request.headers.host; + if (typeof host !== "string" || host.length === 0) { + return null; + } + + const protoHeader = request.headers["x-forwarded-proto"]; + const proto = + typeof protoHeader === "string" ? protoHeader.split(",")[0]?.trim() || "http" : "http"; + return `${proto}://${host}`; +} + +export function isAllowedOrigin(request: http.IncomingMessage): boolean { + const originHeader = request.headers.origin; + if (typeof originHeader !== "string" || originHeader.length === 0) { + return true; + } + const requestOrigin = resolveRequestOrigin(request); + return requestOrigin !== null && requestOrigin === originHeader; +} + +export function validateWebPushOrigin(input: { + request: http.IncomingMessage; + origin: string | null; +}): string | null { + const originHeader = input.request.headers.origin; + if (typeof originHeader !== "string" || originHeader.length === 0) { + return null; + } + if (input.origin === null || input.origin !== originHeader) { + return "Forbidden origin"; + } + return null; +} + +export function buildWebPushConfigResponse(input: { + enabled: boolean; + publicKey: string | null; +}): WebPushConfigResponse { + if (!input.enabled || input.publicKey === null) { + return { enabled: false }; + } + return { + enabled: true, + publicKey: input.publicKey, + serviceWorkerPath: WEB_PUSH_SERVICE_WORKER_PATH, + manifestPath: WEB_PUSH_MANIFEST_PATH, + }; +} + +export function toBadJsonError(error: unknown): Error { + return error instanceof SyntaxError + ? new Error("Malformed JSON body") + : error instanceof Error + ? error + : new Error("Malformed JSON body"); +} diff --git a/apps/server/src/notifications/policy.test.ts b/apps/server/src/notifications/policy.test.ts new file mode 100644 index 000000000..7ff71c35a --- /dev/null +++ b/apps/server/src/notifications/policy.test.ts @@ -0,0 +1,217 @@ +import { + ApprovalRequestId, + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { notificationIntentFromEvent } from "./policy.ts"; + +function makeSnapshot(): OrchestrationReadModel { + return { + snapshotSequence: 7, + updatedAt: "2026-03-16T10:00:00.000Z", + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: "gpt-5.4", + scripts: [], + createdAt: "2026-03-16T09:00:00.000Z", + updatedAt: "2026-03-16T10:00:00.000Z", + deletedAt: null, + }, + ], + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Important thread", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-16T09:59:00.000Z", + startedAt: "2026-03-16T09:59:01.000Z", + completedAt: "2026-03-16T10:00:00.000Z", + assistantMessageId: MessageId.makeUnsafe("assistant-message-1"), + }, + createdAt: "2026-03-16T09:00:00.000Z", + updatedAt: "2026-03-16T10:00:00.000Z", + deletedAt: null, + messages: [ + { + id: MessageId.makeUnsafe("assistant-message-1"), + role: "assistant", + text: "This is the latest response from the assistant with enough detail to trim.", + attachments: [], + turnId: TurnId.makeUnsafe("turn-1"), + streaming: false, + createdAt: "2026-03-16T10:00:00.000Z", + updatedAt: "2026-03-16T10:00:00.000Z", + }, + ], + activities: [], + proposedPlans: [], + checkpoints: [], + session: null, + }, + ], + }; +} + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; + readonly metadata?: OrchestrationEvent["metadata"]; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.makeUnsafe(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + occurredAt: "2026-03-16T10:00:00.000Z", + commandId: CommandId.makeUnsafe(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: input.metadata ?? {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +describe("notificationIntentFromEvent", () => { + it("builds completion notifications from thread.turn-diff-completed", () => { + const payload = notificationIntentFromEvent({ + snapshot: makeSnapshot(), + event: makeEvent({ + sequence: 11, + type: "thread.turn-diff-completed", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant-message-1", + completedAt: "2026-03-16T10:00:00.000Z", + }, + }), + }); + + expect(payload).toEqual( + expect.objectContaining({ + kind: "thread.turn.completed", + threadId: "thread-1", + url: "/thread-1", + tag: "thread-complete:thread-1:turn-1", + requireInteraction: false, + }), + ); + expect(payload?.body.length).toBeLessThanOrEqual(160); + }); + + it("uses request ids for approval notification tags", () => { + const payload = notificationIntentFromEvent({ + snapshot: makeSnapshot(), + event: makeEvent({ + sequence: 12, + type: "thread.activity-appended", + metadata: { + requestId: ApprovalRequestId.makeUnsafe("approval-123"), + }, + payload: { + threadId: "thread-1", + activity: { + id: "activity-approval-1", + tone: "approval", + kind: "approval.requested", + summary: "Deploy to production?", + payload: {}, + turnId: "turn-1", + createdAt: "2026-03-16T10:00:00.000Z", + }, + }, + }), + }); + + expect(payload).toEqual( + expect.objectContaining({ + kind: "thread.approval.requested", + requestId: "approval-123", + tag: "thread-approval:thread-1:approval-123", + requireInteraction: true, + }), + ); + }); + + it("marks user input requests as requireInteraction", () => { + const payload = notificationIntentFromEvent({ + snapshot: makeSnapshot(), + event: makeEvent({ + sequence: 13, + type: "thread.activity-appended", + payload: { + threadId: "thread-1", + activity: { + id: "activity-input-1", + tone: "info", + kind: "user-input.requested", + summary: "Which branch should I use?", + payload: { + requestId: "input-123", + }, + turnId: "turn-1", + createdAt: "2026-03-16T10:00:00.000Z", + }, + }, + }), + }); + + expect(payload).toEqual( + expect.objectContaining({ + kind: "thread.user-input.requested", + requestId: "input-123", + tag: "thread-input:thread-1:input-123", + requireInteraction: true, + }), + ); + }); + + it("returns null for unrelated events", () => { + const payload = notificationIntentFromEvent({ + snapshot: makeSnapshot(), + event: makeEvent({ + sequence: 14, + type: "thread.created", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "Important thread", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: "2026-03-16T09:00:00.000Z", + }, + }), + }); + + expect(payload).toBeNull(); + }); +}); diff --git a/apps/server/src/notifications/policy.ts b/apps/server/src/notifications/policy.ts new file mode 100644 index 000000000..6f29dc969 --- /dev/null +++ b/apps/server/src/notifications/policy.ts @@ -0,0 +1,137 @@ +import type { + OrchestrationMessage, + OrchestrationReadModel, + OrchestrationThread, +} from "@t3tools/contracts"; + +import type { NotificationIntentInput, WebPushPayload } from "./types.ts"; + +const MAX_NOTIFICATION_BODY_LENGTH = 160; + +function normalizeExcerpt(value: string): string { + const collapsed = value.replace(/\s+/g, " ").trim(); + if (collapsed.length <= MAX_NOTIFICATION_BODY_LENGTH) { + return collapsed; + } + return `${collapsed.slice(0, MAX_NOTIFICATION_BODY_LENGTH - 1).trimEnd()}…`; +} + +function findThread( + snapshot: OrchestrationReadModel, + threadId: string, +): OrchestrationThread | undefined { + return snapshot.threads.find((thread) => thread.id === threadId && thread.deletedAt === null); +} + +function findMessage( + thread: OrchestrationThread, + messageId: string | null, +): OrchestrationMessage | undefined { + if (!messageId) { + return undefined; + } + return thread.messages.find((message) => message.id === messageId); +} + +function titleForThread(thread: OrchestrationThread): string { + return thread.title.length > 0 ? thread.title : "Thread"; +} + +export function notificationIntentFromEvent(input: NotificationIntentInput): WebPushPayload | null { + const { event, snapshot } = input; + + switch (event.type) { + case "thread.turn-diff-completed": { + const thread = findThread(snapshot, event.payload.threadId); + if (!thread) { + return null; + } + + const message = findMessage(thread, event.payload.assistantMessageId); + const body = + message && message.text.trim().length > 0 + ? normalizeExcerpt(message.text) + : `New response in ${titleForThread(thread)}`; + + return { + version: 1, + kind: "thread.turn.completed", + eventSequence: event.sequence, + threadId: thread.id, + projectId: thread.projectId, + turnId: event.payload.turnId, + requestId: null, + title: titleForThread(thread), + body, + url: `/${encodeURIComponent(thread.id)}`, + tag: `thread-complete:${thread.id}:${event.payload.turnId}`, + createdAt: event.occurredAt, + requireInteraction: false, + }; + } + + case "thread.activity-appended": { + const thread = findThread(snapshot, event.payload.threadId); + if (!thread) { + return null; + } + + const activity = event.payload.activity; + const requestId = + event.metadata.requestId ?? + (activity.payload && + typeof activity.payload === "object" && + "requestId" in activity.payload && + typeof (activity.payload as { requestId?: unknown }).requestId === "string" + ? (activity.payload as { requestId: string }).requestId + : null); + + if (activity.kind === "approval.requested") { + return { + version: 1, + kind: "thread.approval.requested", + eventSequence: event.sequence, + threadId: thread.id, + projectId: thread.projectId, + turnId: activity.turnId, + requestId, + title: titleForThread(thread), + body: + activity.summary.trim().length > 0 + ? normalizeExcerpt(activity.summary) + : `Action requires approval in ${titleForThread(thread)}`, + url: `/${encodeURIComponent(thread.id)}`, + tag: `thread-approval:${thread.id}:${requestId ?? event.eventId}`, + createdAt: event.occurredAt, + requireInteraction: true, + }; + } + + if (activity.kind === "user-input.requested") { + return { + version: 1, + kind: "thread.user-input.requested", + eventSequence: event.sequence, + threadId: thread.id, + projectId: thread.projectId, + turnId: activity.turnId, + requestId, + title: titleForThread(thread), + body: + activity.summary.trim().length > 0 + ? normalizeExcerpt(activity.summary) + : `Input requested in ${titleForThread(thread)}`, + url: `/${encodeURIComponent(thread.id)}`, + tag: `thread-input:${thread.id}:${requestId ?? event.eventId}`, + createdAt: event.occurredAt, + requireInteraction: true, + }; + } + + return null; + } + + default: + return null; + } +} diff --git a/apps/server/src/notifications/types.ts b/apps/server/src/notifications/types.ts new file mode 100644 index 000000000..eee19d285 --- /dev/null +++ b/apps/server/src/notifications/types.ts @@ -0,0 +1,95 @@ +import { type OrchestrationReadModel, type OrchestrationEvent } from "@t3tools/contracts"; +import { Schema } from "effect"; + +export interface WebPushConfigShape { + readonly enabled: boolean; + readonly publicKey: string | null; + readonly subject: string | null; +} + +export interface WebPushSubscriptionRecord { + readonly subscriptionId: string; + readonly endpoint: string; + readonly subscriptionJson: string; + readonly userAgent: string | null; + readonly appVersion: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastSeenAt: string; + readonly lastDeliveredAt: string | null; + readonly lastFailureAt: string | null; + readonly lastError: string | null; + readonly failureCount: number; + readonly enabled: boolean; +} + +export interface WebPushPayload { + readonly version: 1; + readonly kind: + | "thread.turn.completed" + | "thread.approval.requested" + | "thread.user-input.requested"; + readonly eventSequence: number; + readonly threadId: string; + readonly projectId: string; + readonly turnId: string | null; + readonly requestId: string | null; + readonly title: string; + readonly body: string; + readonly url: string; + readonly tag: string; + readonly createdAt: string; + readonly requireInteraction: boolean; +} + +export const WebPushSubscriptionKeysSchema = Schema.Struct({ + p256dh: Schema.String.check(Schema.isNonEmpty()), + auth: Schema.String.check(Schema.isNonEmpty()), +}); +export type WebPushSubscriptionKeys = typeof WebPushSubscriptionKeysSchema.Type; + +export const WebPushSubscriptionJsonSchema = Schema.Struct({ + endpoint: Schema.String.check(Schema.isNonEmpty()), + expirationTime: Schema.NullOr(Schema.Number), + keys: WebPushSubscriptionKeysSchema, +}); +export type WebPushSubscriptionJson = typeof WebPushSubscriptionJsonSchema.Type; + +export const PutWebPushSubscriptionRequest = Schema.Struct({ + subscription: WebPushSubscriptionJsonSchema, + userAgent: Schema.optional(Schema.NullOr(Schema.String)), + appVersion: Schema.optional(Schema.NullOr(Schema.String)), +}); +export type PutWebPushSubscriptionRequest = typeof PutWebPushSubscriptionRequest.Type; + +export const DeleteWebPushSubscriptionRequest = Schema.Struct({ + subscription: Schema.Struct({ + endpoint: Schema.String.check(Schema.isNonEmpty()), + }), +}); +export type DeleteWebPushSubscriptionRequest = typeof DeleteWebPushSubscriptionRequest.Type; + +export const WebPushConfigResponseSchema = Schema.Union([ + Schema.Struct({ + enabled: Schema.Literal(false), + }), + Schema.Struct({ + enabled: Schema.Literal(true), + publicKey: Schema.String.check(Schema.isNonEmpty()), + serviceWorkerPath: Schema.String.check(Schema.isNonEmpty()), + manifestPath: Schema.String.check(Schema.isNonEmpty()), + }), +]); +export type WebPushConfigResponse = typeof WebPushConfigResponseSchema.Type; + +export class WebPushRequestError extends Schema.TaggedErrorClass()( + "WebPushRequestError", + { + message: Schema.String, + }, +) {} + +export interface NotificationIntentInput { + readonly event: OrchestrationEvent; + readonly snapshot: OrchestrationReadModel; +} diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f..f06e5be86 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -713,6 +713,117 @@ describe("ProviderCommandReactor", () => { }); }); + it("surfaces stale provider user-input failures with a clear recovery message", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.respondToUserInput.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "item/tool/requestUserInput", + detail: "Unknown pending user input request: user-input-request-1", + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input-error"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.makeUnsafe("cmd-user-input-requested"), + threadId: ThreadId.makeUnsafe("thread-1"), + activity: { + id: EventId.makeUnsafe("activity-user-input-requested"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "user-input-request-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.makeUnsafe("cmd-user-input-respond-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { + sandbox_mode: "workspace-write", + }, + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + if (!thread) return false; + return thread.activities.some( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread).toBeDefined(); + + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + expect(failureActivity?.payload).toMatchObject({ + requestId: "user-input-request-1", + detail: + "This question came from an earlier provider session and expired after the session restarted. Please ask the agent to re-ask the question.", + }); + + const resolvedActivity = thread?.activities.find( + (activity) => + activity.kind === "user-input.resolved" && + typeof activity.payload === "object" && + activity.payload !== null && + (activity.payload as Record).requestId === "user-input-request-1", + ); + expect(resolvedActivity).toBeUndefined(); + }); + it("surfaces stale provider approval request failures without faking approval resolution", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe0218845..0877e68e8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -90,6 +90,28 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { + const error = Cause.squash(cause); + if (Schema.is(ProviderAdapterRequestError)(error)) { + return error.detail.toLowerCase().includes("unknown pending user input request"); + } + const message = Cause.pretty(cause).toLowerCase(); + return ( + message.includes("unknown pending user input request") || + message.includes("belongs to a previous provider session") + ); +} + +function describePendingUserInputFailure(cause: Cause.Cause): string { + if (isStalePendingUserInputError(cause)) { + return ( + "This question came from an earlier provider session and expired after the session restarted. " + + "Please ask the agent to re-ask the question." + ); + } + return Cause.pretty(cause); +} + function isTemporaryWorktreeBranch(branch: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); } @@ -578,7 +600,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.user-input.respond.failed", summary: "Provider user input response failed", - detail: Cause.pretty(cause), + detail: describePendingUserInputFailure(cause), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7deb890dd..9d95c36da 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -25,6 +25,7 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; +import Migration0014 from "./Migrations/014_WebPushSubscriptions.ts"; import { Effect } from "effect"; /** @@ -51,6 +52,7 @@ const loader = Migrator.fromRecord({ "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, "12_ProjectionThreadsInteractionMode": Migration0012, "13_ProjectionThreadProposedPlans": Migration0013, + "14_WebPushSubscriptions": Migration0014, }); /** diff --git a/apps/server/src/persistence/Migrations/014_WebPushSubscriptions.ts b/apps/server/src/persistence/Migrations/014_WebPushSubscriptions.ts new file mode 100644 index 000000000..3d04ae0ee --- /dev/null +++ b/apps/server/src/persistence/Migrations/014_WebPushSubscriptions.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS web_push_subscriptions ( + subscription_id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + subscription_json TEXT NOT NULL, + user_agent TEXT, + app_version TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + last_delivered_at TEXT, + last_failure_at TEXT, + last_error TEXT, + failure_count INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_web_push_subscriptions_enabled_updated + ON web_push_subscriptions(enabled, updated_at) + `; +}); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index d5cf4424b..74db09fd4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -493,6 +493,42 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("fails fast for user input replies after the provider session is gone", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-user-input-stale"), { + provider: "codex", + threadId: asThreadId("thread-user-input-stale"), + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(session.threadId); + routing.codex.startSession.mockClear(); + routing.codex.respondToUserInput.mockClear(); + + const response = yield* Effect.result( + provider.respondToUserInput({ + threadId: session.threadId, + requestId: asRequestId("req-user-input-stale"), + answers: { + sandbox_mode: "workspace-write", + }, + }), + ); + + assertFailure( + response, + new ProviderValidationError({ + operation: "ProviderService.respondToUserInput", + issue: + "This question belongs to a previous provider session and can no longer be answered. Ask the agent to re-ask it.", + }), + ); + assert.equal(routing.codex.startSession.mock.calls.length, 0); + assert.equal(routing.codex.respondToUserInput.mock.calls.length, 0); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e3bc7204..a7bd34c6f 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -397,8 +397,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.respondToUserInput", - allowRecovery: true, + allowRecovery: false, }); + if (!routed.isActive) { + return yield* toValidationError( + "ProviderService.respondToUserInput", + "This question belongs to a previous provider session and can no longer be answered. Ask the agent to re-ask it.", + ); + } yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); }); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96..2f586dd8c 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -36,6 +36,8 @@ import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { WebPushSubscriptionRepositoryLive } from "./notifications/Layers/WebPushSubscriptionRepository.ts"; +import { WebPushNotificationsLive } from "./notifications/Layers/WebPushNotifications.ts"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, @@ -121,11 +123,18 @@ export function makeServerRuntimeServicesLayer() { Layer.provideMerge(textGenerationLayer), ); + const webPushLayer = WebPushNotificationsLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(WebPushSubscriptionRepositoryLive), + ); + return Layer.mergeAll( + runtimeServicesLayer, orchestrationReactorLayer, gitCoreLayer, gitManagerLayer, terminalLayer, KeybindingsLive, + webPushLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a31..50c4fc38e 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -474,6 +474,9 @@ describe("WebSocket Server", () => { logWebSocketEvents?: boolean; devUrl?: string; authToken?: string; + webPushVapidPublicKey?: string; + webPushVapidPrivateKey?: string; + webPushSubject?: string; stateDir?: string; staticDir?: string; providerLayer?: Layer.Layer; @@ -508,6 +511,9 @@ describe("WebSocket Server", () => { devUrl: options.devUrl ? new URL(options.devUrl) : undefined, noBrowser: true, authToken: options.authToken, + webPushVapidPublicKey: options.webPushVapidPublicKey, + webPushVapidPrivateKey: options.webPushVapidPrivateKey, + webPushSubject: options.webPushSubject, autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), } satisfies ServerConfigShape); @@ -607,6 +613,71 @@ describe("WebSocket Server", () => { expect(bytes).toEqual(Buffer.from("hello-attachment")); }); + it("returns disabled web push config when VAPID keys are missing", async () => { + server = await createTestServer({ cwd: "/test/project" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await fetch(`http://127.0.0.1:${port}/api/web-push/config`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ enabled: false }); + }); + + it("rejects subscription writes when web push is not configured", async () => { + server = await createTestServer({ cwd: "/test/project" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await fetch(`http://127.0.0.1:${port}/api/web-push/subscription`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + subscription: { + endpoint: "https://example.com/subscriptions/1", + expirationTime: null, + keys: { + p256dh: "public-key", + auth: "auth-key", + }, + }, + }), + }); + + const body = await response.text(); + expect([400, 409]).toContain(response.status); + expect(body.length).toBeGreaterThan(0); + }); + + it("rejects cross-origin web push subscription writes", async () => { + server = await createTestServer({ cwd: "/test/project" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await fetch(`http://127.0.0.1:${port}/api/web-push/subscription`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "https://evil.example", + }, + body: JSON.stringify({ + subscription: { + endpoint: "https://example.com/subscriptions/1", + expirationTime: null, + keys: { + p256dh: "public-key", + auth: "auth-key", + }, + }, + }), + }); + + expect(response.status).toBe(403); + expect(await response.text()).toContain("Forbidden origin"); + }); + it("serves persisted attachments for URL-encoded paths", async () => { const stateDir = makeTempDir("t3code-state-attachments-encoded-"); const attachmentPath = path.join( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..bffcc1a01 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -78,6 +78,19 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { WebPushNotifications } from "./notifications/Services/WebPushNotifications.ts"; +import { + decodeDeleteSubscriptionBody, + decodePutSubscriptionBody, + hasJsonContentType, + isAllowedOrigin, + isWebPushConfigRequest, + isWebPushSubscribeRequest, + isWebPushUnsubscribeRequest, + readJsonRequestBody, + toBadJsonError, +} from "./notifications/http.ts"; +import { WebPushRequestError } from "./notifications/types.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -110,6 +123,30 @@ const isServerNotRunningError = (error: Error): boolean => { ); }; +const isRouteRequestError = (error: unknown): error is RouteRequestError => + Schema.is(RouteRequestError)(error); + +const isWebPushRequestError = (error: unknown): error is WebPushRequestError => + Schema.is(WebPushRequestError)(error); + +const errorMessage = (error: unknown): string => { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return String(error); +}; + function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { socket.end( `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + @@ -217,7 +254,8 @@ export type ServerRuntimeServices = | TerminalManager | Keybindings | Open - | AnalyticsService; + | AnalyticsService + | WebPushNotifications; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -227,9 +265,12 @@ export class ServerLifecycleError extends Schema.TaggedErrorClass()("RouteRequestError", { - message: Schema.String, -}) {} +export class RouteRequestError extends Schema.TaggedErrorClass()( + "RouteRequestError", + { + message: Schema.String, + }, +) {} export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, @@ -255,6 +296,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; const git = yield* GitCore; + const webPushNotifications = yield* WebPushNotifications; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -424,6 +466,117 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< void Effect.runPromise( Effect.gen(function* () { const url = new URL(req.url ?? "/", `http://localhost:${port}`); + if (isWebPushConfigRequest(req.method, url.pathname)) { + const response = webPushNotifications.config.enabled + ? { + enabled: true, + publicKey: webPushNotifications.config.publicKey ?? "", + serviceWorkerPath: "/service-worker.js", + manifestPath: "/manifest.webmanifest", + } + : { enabled: false }; + respond( + 200, + { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + }, + JSON.stringify(response), + ); + return; + } + + if (isWebPushSubscribeRequest(req.method, url.pathname)) { + if (!hasJsonContentType(req)) { + respond(415, { "Content-Type": "text/plain" }, "Expected application/json body"); + return; + } + if (!isAllowedOrigin(req)) { + respond(403, { "Content-Type": "text/plain" }, "Forbidden origin"); + return; + } + + const jsonBody = yield* Effect.tryPromise({ + try: () => readJsonRequestBody(req), + catch: (error) => + new RouteRequestError({ + message: toBadJsonError(error).message, + }), + }); + const body = decodePutSubscriptionBody(jsonBody); + if (body instanceof Error) { + return yield* new RouteRequestError({ + message: body.message, + }); + } + + yield* webPushNotifications + .subscribe({ + subscription: body.subscription, + userAgent: body.userAgent ?? null, + appVersion: body.appVersion ?? null, + }) + .pipe( + Effect.mapError( + (error) => + new RouteRequestError({ + message: errorMessage(error), + }), + ), + ); + respond(204, { "Cache-Control": "no-store" }); + return; + } + + if (isWebPushUnsubscribeRequest(req.method, url.pathname)) { + if (!hasJsonContentType(req)) { + respond(415, { "Content-Type": "text/plain" }, "Expected application/json body"); + return; + } + if (!isAllowedOrigin(req)) { + respond(403, { "Content-Type": "text/plain" }, "Forbidden origin"); + return; + } + + const jsonBody = yield* Effect.tryPromise({ + try: () => readJsonRequestBody(req), + catch: (error) => + new RouteRequestError({ + message: toBadJsonError(error).message, + }), + }); + const body = decodeDeleteSubscriptionBody(jsonBody); + if (body instanceof Error) { + return yield* new RouteRequestError({ + message: body.message, + }); + } + + yield* webPushNotifications + .unsubscribe({ + subscription: body.subscription, + }) + .pipe( + Effect.mapError( + (error) => + new RouteRequestError({ + message: errorMessage(error), + }), + ), + ); + respond(204, { "Cache-Control": "no-store" }); + return; + } + + if (url.pathname === "/api/web-push/subscription") { + respond( + 405, + { Allow: "PUT, DELETE", "Content-Type": "text/plain" }, + "Method Not Allowed", + ); + return; + } + if (tryHandleProjectFaviconRequest(url, res)) { return; } @@ -567,10 +720,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } respond(200, { "Content-Type": contentType }, data); }), - ).catch(() => { - if (!res.headersSent) { - respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); + ).catch((error) => { + if (res.headersSent) { + return; } + const message = errorMessage(error); + if (message.includes("not configured")) { + respond(409, { "Content-Type": "text/plain" }, message); + return; + } + if (isRouteRequestError(error)) { + const statusCode = + error.message.includes("Cross-origin") || error.message.includes("Forbidden origin") + ? 403 + : error.message.includes("Malformed JSON") || error.message.includes("Invalid request") + ? 400 + : 400; + respond(statusCode, { "Content-Type": "text/plain" }, error.message); + return; + } + if (isWebPushRequestError(error)) { + const statusCode = error.message.includes("not configured") ? 409 : 400; + respond(statusCode, { "Content-Type": "text/plain" }, error.message); + return; + } + respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); }); }); @@ -610,6 +784,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event), ).pipe(Effect.forkIn(subscriptionsScope)); + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + webPushNotifications.notifyEvent(event), + ).pipe(Effect.forkIn(subscriptionsScope)); yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d01..f845cb05b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,8 +3,16 @@ + + + + + + + + { + event.waitUntil( + (async () => { + const cache = await caches.open(APP_SHELL_CACHE); + await cache.addAll(APP_SHELL_ASSETS); + await self.skipWaiting(); + })(), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames + .filter((cacheName) => cacheName !== APP_SHELL_CACHE) + .map((cacheName) => caches.delete(cacheName)), + ); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") { + return; + } + + const url = new URL(request.url); + + if (isAppNavigation(request, url)) { + event.respondWith( + (async () => { + try { + const response = await fetch(request); + const cache = await caches.open(APP_SHELL_CACHE); + await cache.put(APP_SHELL_URL, response.clone()); + return response; + } catch { + const cachedShell = await caches.match(APP_SHELL_URL); + return cachedShell ?? Response.error(); + } + })(), + ); + return; + } + + if (url.origin !== self.location.origin || !APP_SHELL_ASSETS.includes(url.pathname)) { + return; + } + + event.respondWith( + (async () => { + const cached = await caches.match(request); + if (cached) { + return cached; + } + + const response = await fetch(request); + const cache = await caches.open(APP_SHELL_CACHE); + await cache.put(request, response.clone()); + return response; + })(), + ); +}); + +self.addEventListener("push", (event) => { + const payload = parsePushPayload(event); + if (!payload || typeof payload.title !== "string" || typeof payload.url !== "string") { + return; + } + + event.waitUntil( + (async () => { + const windowClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + + if (windowClients.some((client) => client.visibilityState === "visible")) { + return; + } + + await self.registration.showNotification(payload.title, { + body: typeof payload.body === "string" ? payload.body : "", + tag: typeof payload.tag === "string" ? payload.tag : undefined, + requireInteraction: Boolean(payload.requireInteraction), + renotify: false, + data: { + url: payload.url, + threadId: payload.threadId ?? null, + kind: payload.kind ?? null, + }, + }); + })(), + ); +}); + +self.addEventListener("notificationclick", (event) => { + const rawUrl = event.notification?.data?.url; + const targetUrl = resolveNotificationUrl(typeof rawUrl === "string" ? rawUrl : "/"); + + event.notification.close(); + event.waitUntil( + (async () => { + const windowClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + + for (const client of windowClients) { + const clientUrl = new URL(client.url); + if (clientUrl.origin !== targetUrl.origin) { + continue; + } + + await client.focus(); + if ("navigate" in client) { + await client.navigate(targetUrl.href); + } + return; + } + + await self.clients.openWindow(targetUrl.href); + })(), + ); +}); diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 000000000..af9590f91 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1 @@ +importScripts("/service-worker.js"); diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaac..8e4aa33a9 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_APP_SETTINGS, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, normalizeCustomModelSlugs, @@ -64,3 +65,9 @@ describe("timestamp format defaults", () => { expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); }); + +describe("push notification defaults", () => { + it("defaults push notifications to disabled", () => { + expect(DEFAULT_APP_SETTINGS.pushNotificationsEnabled).toBe(false); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..9f24aa4d1 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -28,6 +28,9 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + pushNotificationsEnabled: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -42,7 +45,7 @@ export interface AppModelOption { isCustom: boolean; } -const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +export const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); export function normalizeCustomModelSlugs( models: Iterable, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..d31a09b48 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -21,6 +21,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; +import { stopPlayback } from "../features/tts/tts"; import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; @@ -91,6 +92,23 @@ interface MountedChatView { router: ReturnType; } +class MockSpeechSynthesisUtterance { + readonly text: string; + lang = ""; + voice: SpeechSynthesisVoice | null = null; + onend: (() => void) | null = null; + onerror: ((event: { error?: string }) => void) | null = null; + + constructor(text: string) { + this.text = text; + } +} + +interface BrowserSpeechMockState { + readonly speakCalls: MockSpeechSynthesisUtterance[]; + cancelCount: number; +} + function isoAt(offsetSeconds: number): string { return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); } @@ -150,6 +168,49 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe }; } +function createSnapshotForAssistantTts(options: { + assistantMessages: ReadonlyArray<{ + id: MessageId; + text: string; + streaming?: boolean; + }>; +}): OrchestrationReadModel { + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-assistant-tts" as MessageId, + targetText: "assistant tts target", + }); + + return { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + messages: options.assistantMessages.flatMap((assistantMessage, index) => { + const offsetSeconds = index * 4; + return [ + createUserMessage({ + id: `msg-user-assistant-tts-${index}` as MessageId, + text: `user message ${index + 1}`, + offsetSeconds, + }), + { + ...createAssistantMessage({ + id: assistantMessage.id, + text: assistantMessage.text, + offsetSeconds: offsetSeconds + 1, + }), + streaming: Boolean(assistantMessage.streaming), + updatedAt: isoAt(offsetSeconds + 2), + }, + ]; + }), + } + : thread, + ), + }; +} + function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; @@ -543,6 +604,47 @@ async function waitForInteractionModeButton( ); } +async function waitForButtonByTitle(title: string): Promise { + return waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.title === title, + ) as HTMLButtonElement | null, + `Unable to find button titled "${title}".`, + ); +} + +function queryButtonByTitle(title: string): HTMLButtonElement | null { + return ( + (Array.from(document.querySelectorAll("button")).find((button) => button.title === title) as + | HTMLButtonElement + | undefined) ?? null + ); +} + +function installSpeechSynthesisMock(): BrowserSpeechMockState { + const state: BrowserSpeechMockState = { + speakCalls: [], + cancelCount: 0, + }; + + vi.stubGlobal("speechSynthesis", { + cancel: vi.fn(() => { + state.cancelCount += 1; + }), + getVoices: vi.fn(() => [{ default: true, lang: "en-US" } as SpeechSynthesisVoice]), + speak: vi.fn((utterance: SpeechSynthesisUtterance) => { + state.speakCalls.push(utterance as unknown as MockSpeechSynthesisUtterance); + }), + } satisfies Partial); + vi.stubGlobal( + "SpeechSynthesisUtterance", + MockSpeechSynthesisUtterance as unknown as typeof SpeechSynthesisUtterance, + ); + + return state; +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); if (images.length === 0) { @@ -716,6 +818,8 @@ describe("ChatView timeline estimator parity (full app)", () => { localStorage.clear(); document.body.innerHTML = ""; wsRequests.length = 0; + stopPlayback(); + vi.unstubAllGlobals(); useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, @@ -729,6 +833,8 @@ describe("ChatView timeline estimator parity (full app)", () => { }); afterEach(() => { + stopPlayback(); + vi.unstubAllGlobals(); document.body.innerHTML = ""; }); @@ -1247,4 +1353,153 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("renders a TTS play button for completed assistant messages and speaks sanitized text", async () => { + const speech = installSpeechSynthesisMock(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForAssistantTts({ + assistantMessages: [ + { + id: "msg-assistant-tts-play" as MessageId, + text: [ + "Here is the answer.", + "", + "```ts", + "const value = 1;", + "```", + "", + "See [the docs](https://example.com/docs).", + ].join("\n"), + }, + ], + }), + }); + + try { + const playButton = await waitForButtonByTitle("Play message"); + playButton.click(); + + await vi.waitFor( + () => { + expect(speech.speakCalls).toHaveLength(1); + expect(speech.speakCalls[0]?.text).toBe( + [ + "Here is the answer.", + "", + "TypeScript Code Block - Open the chat to view the code.", + "", + "See the docs.", + ].join("\n"), + ); + expect(queryButtonByTitle("Stop playback")).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("does not render a TTS button for streaming assistant messages", async () => { + installSpeechSynthesisMock(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForAssistantTts({ + assistantMessages: [ + { + id: "msg-assistant-tts-streaming" as MessageId, + text: "Still streaming", + streaming: true, + }, + ], + }), + }); + + try { + await waitForLayout(); + expect(queryButtonByTitle("Play message")).toBeNull(); + expect(queryButtonByTitle("Stop playback")).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + + it("stops playback when the active assistant TTS button is clicked again", async () => { + const speech = installSpeechSynthesisMock(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForAssistantTts({ + assistantMessages: [ + { + id: "msg-assistant-tts-stop" as MessageId, + text: "Stop me if you have heard enough.", + }, + ], + }), + }); + + try { + (await waitForButtonByTitle("Play message")).click(); + (await waitForButtonByTitle("Stop playback")).click(); + + await vi.waitFor( + () => { + expect(speech.cancelCount).toBe(2); + expect(queryButtonByTitle("Stop playback")).toBeNull(); + expect(queryButtonByTitle("Play message")).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("switches TTS playback when a different assistant message is selected", async () => { + const speech = installSpeechSynthesisMock(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForAssistantTts({ + assistantMessages: [ + { + id: "msg-assistant-tts-first" as MessageId, + text: "First response.", + }, + { + id: "msg-assistant-tts-second" as MessageId, + text: "Second response.", + }, + ], + }), + }); + + try { + let playButtons: HTMLButtonElement[] = []; + await vi.waitFor( + () => { + playButtons = Array.from(document.querySelectorAll("button")).filter( + (button) => button.title === "Play message", + ) as HTMLButtonElement[]; + expect(playButtons).toHaveLength(2); + }, + { timeout: 8_000, interval: 16 }, + ); + + playButtons[0]?.click(); + playButtons[1]?.click(); + + await vi.waitFor( + () => { + expect(speech.speakCalls).toHaveLength(2); + expect(speech.speakCalls[0]?.text).toBe("First response."); + expect(speech.speakCalls[1]?.text).toBe("Second response."); + expect(speech.cancelCount).toBe(2); + }, + { 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 9f625762c..cc348a804 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -106,6 +106,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; +import { logUserInputDebug } from "~/debug/userInputDebug"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -273,6 +274,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const lastPendingUserInputDebugStateRef = useRef(null); + const lastThreadErrorDebugValueRef = useRef(null); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -2516,24 +2519,47 @@ export default function ChatView({ threadId }: ChatViewProps) { const api = readNativeApi(); if (!api || !activeThreadId) return; + logUserInputDebug({ + level: "info", + stage: "dispatch-start", + message: "Dispatching thread.user-input.respond", + threadId: activeThreadId, + requestId, + detail: JSON.stringify(answers, null, 2), + }); setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); - await api.orchestration - .dispatchCommand({ + try { + const result = await api.orchestration.dispatchCommand({ type: "thread.user-input.respond", commandId: newCommandId(), threadId: activeThreadId, requestId, answers, createdAt: new Date().toISOString(), - }) - .catch((err: unknown) => { - setStoreThreadError( - activeThreadId, - err instanceof Error ? err.message : "Failed to submit user input.", - ); }); + logUserInputDebug({ + level: "success", + stage: "dispatch-success", + message: "thread.user-input.respond accepted by orchestration", + threadId: activeThreadId, + requestId, + detail: JSON.stringify(result, null, 2), + }); + } catch (err: unknown) { + logUserInputDebug({ + level: "error", + stage: "dispatch-error", + message: err instanceof Error ? err.message : "Failed to submit user input.", + threadId: activeThreadId, + requestId, + }); + setStoreThreadError( + activeThreadId, + err instanceof Error ? err.message : "Failed to submit user input.", + ); + } setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, setStoreThreadError], @@ -2557,6 +2583,14 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } + logUserInputDebug({ + level: "info", + stage: "option-selected", + message: `Selected option "${optionLabel}"`, + threadId: activeThreadId, + requestId: activePendingUserInput.requestId, + detail: JSON.stringify({ questionId }, null, 2), + }); setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { @@ -2571,7 +2605,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); }, - [activePendingUserInput], + [activePendingUserInput, activeThreadId], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -2606,16 +2640,50 @@ export default function ChatView({ threadId }: ChatViewProps) { const onAdvanceActivePendingUserInput = useCallback(() => { if (!activePendingUserInput || !activePendingProgress) { + logUserInputDebug({ + level: "warning", + stage: "advance-click", + message: "Advance clicked without an active pending question", + threadId: activeThreadId, + }); return; } + logUserInputDebug({ + level: "info", + stage: "advance-click", + message: activePendingProgress.isLastQuestion + ? "Submit answers clicked" + : "Next question clicked", + threadId: activeThreadId, + requestId: activePendingUserInput.requestId, + detail: JSON.stringify( + { + questionIndex: activePendingProgress.questionIndex, + isLastQuestion: activePendingProgress.isLastQuestion, + canAdvance: activePendingProgress.canAdvance, + hasResolvedAnswers: activePendingResolvedAnswers !== null, + }, + null, + 2, + ), + }); if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + } else { + logUserInputDebug({ + level: "warning", + stage: "submit-blocked", + message: "Submit was clicked but answers were not fully resolved", + threadId: activeThreadId, + requestId: activePendingUserInput.requestId, + }); } return; } setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }, [ + activeThreadId, activePendingProgress, activePendingResolvedAnswers, activePendingUserInput, @@ -2623,6 +2691,65 @@ export default function ChatView({ threadId }: ChatViewProps) { setActivePendingUserInputQuestionIndex, ]); + useEffect(() => { + if (!activeThreadId) { + lastPendingUserInputDebugStateRef.current = null; + return; + } + const nextState = JSON.stringify({ + pendingCount: pendingUserInputs.length, + activeRequestId: activePendingUserInput?.requestId ?? null, + questionIndex: activePendingQuestionIndex, + isResponding: activePendingIsResponding, + isLastQuestion: activePendingProgress?.isLastQuestion ?? null, + canAdvance: activePendingProgress?.canAdvance ?? null, + isComplete: activePendingProgress?.isComplete ?? null, + answeredQuestionCount: activePendingProgress?.answeredQuestionCount ?? null, + hasResolvedAnswers: activePendingResolvedAnswers !== null, + }); + if (lastPendingUserInputDebugStateRef.current === nextState) { + return; + } + lastPendingUserInputDebugStateRef.current = nextState; + logUserInputDebug({ + level: "info", + stage: "pending-state", + message: "Pending user input state changed", + threadId: activeThreadId, + requestId: activePendingUserInput?.requestId ?? null, + detail: nextState, + }); + }, [ + activePendingIsResponding, + activePendingProgress?.answeredQuestionCount, + activePendingProgress?.canAdvance, + activePendingProgress?.isComplete, + activePendingProgress?.isLastQuestion, + activePendingQuestionIndex, + activePendingResolvedAnswers, + activePendingUserInput?.requestId, + activeThreadId, + pendingUserInputs.length, + ]); + + useEffect(() => { + const nextError = activeThread?.error ?? null; + if (lastThreadErrorDebugValueRef.current === nextError) { + return; + } + lastThreadErrorDebugValueRef.current = nextError; + if (!nextError) { + return; + } + logUserInputDebug({ + level: "error", + stage: "thread-error", + message: nextError, + threadId: activeThread?.id ?? activeThreadId, + requestId: activePendingUserInput?.requestId ?? null, + }); + }, [activePendingUserInput?.requestId, activeThread?.error, activeThread?.id, activeThreadId]); + const onPreviousActivePendingUserInputQuestion = useCallback(() => { if (!activePendingProgress) { return; @@ -3636,9 +3763,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : null} + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/debug/userInputDebug.ts b/apps/web/src/debug/userInputDebug.ts new file mode 100644 index 000000000..1d3901bec --- /dev/null +++ b/apps/web/src/debug/userInputDebug.ts @@ -0,0 +1,242 @@ +import { create } from "zustand"; + +const USER_INPUT_DEBUG_QUERY_PARAM = "debugUserInput"; +const USER_INPUT_DEBUG_STORAGE_KEY = "t3code:debug-user-input"; +const MAX_DEBUG_ENTRIES = 200; + +type UserInputDebugLevel = "info" | "success" | "warning" | "error"; + +export interface UserInputDebugEntry { + id: string; + timestamp: string; + level: UserInputDebugLevel; + stage: string; + message: string; + threadId?: string | null; + requestId?: string | null; + detail?: string; +} + +interface UserInputDebugState { + enabled: boolean; + collapsed: boolean; + position: { x: number; y: number } | null; + entries: UserInputDebugEntry[]; + setEnabled: (enabled: boolean) => void; + setCollapsed: (collapsed: boolean) => void; + setPosition: (position: { x: number; y: number } | null) => void; + pushEntry: (entry: Omit) => void; + clear: () => void; +} + +function readSearchParamEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + const value = new URLSearchParams(window.location.search).get(USER_INPUT_DEBUG_QUERY_PARAM); + return value === "1" || value === "true" || value === "on"; +} + +function canPersistDebugState(): boolean { + if (typeof window === "undefined") { + return false; + } + const hostname = window.location.hostname.toLowerCase(); + return ( + hostname === "t3-dev.claude.do" || + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" + ); +} + +function readPersistedEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + try { + const raw = window.localStorage.getItem(USER_INPUT_DEBUG_STORAGE_KEY); + if (!raw) { + return false; + } + if (raw === "1") { + return true; + } + const parsed = JSON.parse(raw) as { enabled?: boolean }; + return parsed.enabled === true; + } catch { + return false; + } +} + +function readPersistedLayout(): { + collapsed: boolean; + position: { x: number; y: number } | null; +} { + if (typeof window === "undefined") { + return { collapsed: false, position: null }; + } + try { + const raw = window.localStorage.getItem(USER_INPUT_DEBUG_STORAGE_KEY); + if (!raw || raw === "1") { + return { collapsed: false, position: null }; + } + const parsed = JSON.parse(raw) as { + collapsed?: boolean; + position?: { x?: number; y?: number } | null; + }; + return { + collapsed: parsed.collapsed === true, + position: + parsed.position && + typeof parsed.position.x === "number" && + typeof parsed.position.y === "number" + ? { x: parsed.position.x, y: parsed.position.y } + : null, + }; + } catch { + return { collapsed: false, position: null }; + } +} + +function persistDebugState(input: { + enabled: boolean; + collapsed: boolean; + position: { x: number; y: number } | null; +}): void { + if (typeof window === "undefined") { + return; + } + try { + if (!canPersistDebugState()) { + window.localStorage.removeItem(USER_INPUT_DEBUG_STORAGE_KEY); + return; + } + if (input.enabled) { + window.localStorage.setItem( + USER_INPUT_DEBUG_STORAGE_KEY, + JSON.stringify({ + enabled: true, + collapsed: input.collapsed, + position: input.position, + }), + ); + return; + } + window.localStorage.removeItem(USER_INPUT_DEBUG_STORAGE_KEY); + } catch { + // Ignore storage write failures in debug mode. + } +} + +function resolveInitialEnabled(): boolean { + const enabled = readSearchParamEnabled() || (canPersistDebugState() && readPersistedEnabled()); + if (enabled) { + const layout = readPersistedLayout(); + persistDebugState({ + enabled: true, + collapsed: layout.collapsed, + position: layout.position, + }); + } + return enabled; +} + +function nextDebugId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `user-input-debug-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +const initialEnabled = resolveInitialEnabled(); +const initialLayout = readPersistedLayout(); + +export const useUserInputDebugStore = create((set) => ({ + enabled: initialEnabled, + collapsed: initialLayout.collapsed, + position: initialLayout.position, + entries: [], + setEnabled: (enabled) => { + set((state) => { + persistDebugState({ + enabled, + collapsed: enabled ? state.collapsed : false, + position: enabled ? state.position : null, + }); + return { + enabled, + collapsed: enabled ? state.collapsed : false, + position: enabled ? state.position : null, + }; + }); + }, + setCollapsed: (collapsed) => { + set((state) => { + persistDebugState({ + enabled: state.enabled, + collapsed, + position: state.position, + }); + return { collapsed }; + }); + }, + setPosition: (position) => { + set((state) => { + persistDebugState({ + enabled: state.enabled, + collapsed: state.collapsed, + position, + }); + return { position }; + }); + }, + pushEntry: (entry) => + set((state) => { + if (!state.enabled) { + return state; + } + const nextEntry: UserInputDebugEntry = { + ...entry, + id: nextDebugId(), + timestamp: new Date().toISOString(), + }; + const nextEntries = [...state.entries, nextEntry]; + return { + entries: + nextEntries.length > MAX_DEBUG_ENTRIES + ? nextEntries.slice(nextEntries.length - MAX_DEBUG_ENTRIES) + : nextEntries, + }; + }), + clear: () => set({ entries: [] }), +})); + +export function logUserInputDebug(entry: Omit): void { + const store = useUserInputDebugStore.getState(); + if (!store.enabled) { + return; + } + store.pushEntry(entry); + console.debug("[user-input-debug]", entry); +} + +export function setUserInputDebugEnabled(enabled: boolean): void { + useUserInputDebugStore.getState().setEnabled(enabled); +} + +export function setUserInputDebugCollapsed(collapsed: boolean): void { + useUserInputDebugStore.getState().setCollapsed(collapsed); +} + +export function setUserInputDebugPosition(position: { x: number; y: number } | null): void { + useUserInputDebugStore.getState().setPosition(position); +} + +export function clearUserInputDebugEntries(): void { + useUserInputDebugStore.getState().clear(); +} + +export function isUserInputDebugEnabled(): boolean { + return useUserInputDebugStore.getState().enabled; +} diff --git a/apps/web/src/features/tts/AssistantMessageTtsButton.tsx b/apps/web/src/features/tts/AssistantMessageTtsButton.tsx new file mode 100644 index 000000000..e84b44c7e --- /dev/null +++ b/apps/web/src/features/tts/AssistantMessageTtsButton.tsx @@ -0,0 +1,32 @@ +import { memo } from "react"; +import { PlayIcon, SquareIcon } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { useMessageTts } from "./useMessageTts"; + +export const AssistantMessageTtsButton = memo(function AssistantMessageTtsButton({ + messageId, + text, +}: { + messageId: string; + text: string; +}) { + const { supported, canPlay, isPlaying, title, toggle } = useMessageTts(messageId, text); + + if (!supported || !canPlay) { + return null; + } + + return ( + + ); +}); diff --git a/apps/web/src/features/tts/nativeSpeechSynthesis.ts b/apps/web/src/features/tts/nativeSpeechSynthesis.ts new file mode 100644 index 000000000..66f2ba55b --- /dev/null +++ b/apps/web/src/features/tts/nativeSpeechSynthesis.ts @@ -0,0 +1,120 @@ +export interface NativeSpeechSpeakInput { + readonly text: string; + readonly lang?: string; + readonly onEnd: () => void; + readonly onError: (error: Error) => void; +} + +export interface NativeSpeechController { + readonly isSupported: () => boolean; + readonly speak: (input: NativeSpeechSpeakInput) => void; + readonly stop: () => void; +} + +function getSpeechEnvironment(): { + readonly speechSynthesis?: SpeechSynthesis; + readonly SpeechSynthesisUtterance?: typeof SpeechSynthesisUtterance; + readonly navigator?: Navigator; + readonly document?: Document; +} { + const candidate = globalThis as typeof globalThis & { + speechSynthesis?: SpeechSynthesis; + SpeechSynthesisUtterance?: typeof SpeechSynthesisUtterance; + navigator?: Navigator; + document?: Document; + }; + + return { + speechSynthesis: candidate.speechSynthesis, + SpeechSynthesisUtterance: candidate.SpeechSynthesisUtterance, + navigator: candidate.navigator, + document: candidate.document, + }; +} + +function resolveSpeechLang(explicitLang?: string): string | undefined { + const lang = explicitLang?.trim(); + if (lang) { + return lang; + } + + const environment = getSpeechEnvironment(); + const documentLang = environment.document?.documentElement.lang?.trim(); + if (documentLang) { + return documentLang; + } + + const navigatorLang = environment.navigator?.language?.trim(); + return navigatorLang || undefined; +} + +function selectVoice( + speechSynthesis: SpeechSynthesis, + lang: string | undefined, +): SpeechSynthesisVoice | null { + const voices = speechSynthesis.getVoices(); + if (voices.length === 0) { + return null; + } + + if (!lang) { + return voices.find((voice) => voice.default) ?? voices[0] ?? null; + } + + const normalizedLang = lang.toLowerCase(); + const primaryLanguage = normalizedLang.split("-")[0]; + const exactMatch = voices.find((voice) => voice.lang.toLowerCase() === normalizedLang); + if (exactMatch) { + return exactMatch; + } + + const primaryMatch = voices.find((voice) => + voice.lang.toLowerCase().startsWith(`${primaryLanguage}-`), + ); + if (primaryMatch) { + return primaryMatch; + } + + return voices.find((voice) => voice.default) ?? voices[0] ?? null; +} + +export function createNativeSpeechController(): NativeSpeechController { + return { + isSupported() { + const environment = getSpeechEnvironment(); + return Boolean(environment.speechSynthesis && environment.SpeechSynthesisUtterance); + }, + + speak(input) { + const environment = getSpeechEnvironment(); + if (!environment.speechSynthesis || !environment.SpeechSynthesisUtterance) { + throw new Error("Speech synthesis unavailable."); + } + + const utterance = new environment.SpeechSynthesisUtterance(input.text); + const lang = resolveSpeechLang(input.lang); + if (lang) { + utterance.lang = lang; + } + + const voice = selectVoice(environment.speechSynthesis, utterance.lang || lang); + if (voice) { + utterance.voice = voice; + } + + utterance.onend = () => { + input.onEnd(); + }; + utterance.onerror = (event) => { + input.onError(new Error(event.error || "Speech synthesis failed.")); + }; + + environment.speechSynthesis.speak(utterance); + }, + + stop() { + const environment = getSpeechEnvironment(); + environment.speechSynthesis?.cancel(); + }, + }; +} diff --git a/apps/web/src/features/tts/sanitizeTtsText.test.ts b/apps/web/src/features/tts/sanitizeTtsText.test.ts new file mode 100644 index 000000000..72fcb59ae --- /dev/null +++ b/apps/web/src/features/tts/sanitizeTtsText.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeAssistantMessageForTts } from "./sanitizeTtsText"; + +describe("sanitizeAssistantMessageForTts", () => { + it("preserves plain prose while normalizing whitespace", () => { + expect(sanitizeAssistantMessageForTts("Hello world.\n\n")).toBe("Hello world."); + }); + + it("keeps markdown link labels and drops URLs", () => { + expect( + sanitizeAssistantMessageForTts("Read [the docs](https://example.com/docs) for more details."), + ).toBe("Read the docs for more details."); + }); + + it("flattens inline code into readable prose", () => { + expect(sanitizeAssistantMessageForTts("Run `bun lint` before shipping.")).toBe( + "Run bun lint before shipping.", + ); + }); + + it("replaces labeled code fences with a language-specific placeholder", () => { + expect(sanitizeAssistantMessageForTts("```ts\nconst value = 1;\n```")).toBe( + "TypeScript Code Block - Open the chat to view the code.", + ); + }); + + it("replaces unlabeled code fences with a generic placeholder", () => { + expect(sanitizeAssistantMessageForTts("```\nconst value = 1;\n```")).toBe( + "Code Block - Open the chat to view the code.", + ); + }); + + it("replaces multiple code fences independently", () => { + expect( + sanitizeAssistantMessageForTts( + ["```python", "print('hi')", "```", "", "```sh", "echo hi", "```"].join("\n"), + ), + ).toBe( + [ + "Python Code Block - Open the chat to view the code.", + "", + "Shell Code Block - Open the chat to view the code.", + ].join("\n"), + ); + }); + + it("returns empty string for markdown-only filler without speakable text", () => { + expect(sanitizeAssistantMessageForTts("###\n\n---\n\n>")).toBe(""); + }); +}); diff --git a/apps/web/src/features/tts/sanitizeTtsText.ts b/apps/web/src/features/tts/sanitizeTtsText.ts new file mode 100644 index 000000000..8a38adf63 --- /dev/null +++ b/apps/web/src/features/tts/sanitizeTtsText.ts @@ -0,0 +1,106 @@ +const CODE_BLOCK_SUFFIX = "Code Block - Open the chat to view the code."; + +const CODE_LANGUAGE_LABELS: Record = { + bash: "Shell", + c: "C", + "c#": "C Sharp", + "c++": "C Plus Plus", + cpp: "C Plus Plus", + cs: "C Sharp", + css: "CSS", + go: "Go", + html: "HTML", + java: "Java", + javascript: "JavaScript", + js: "JavaScript", + json: "JSON", + jsx: "JavaScript", + markdown: "Markdown", + md: "Markdown", + php: "PHP", + py: "Python", + python: "Python", + rb: "Ruby", + ruby: "Ruby", + rust: "Rust", + sh: "Shell", + shell: "Shell", + sql: "SQL", + swift: "Swift", + ts: "TypeScript", + tsx: "TypeScript", + typescript: "TypeScript", + xml: "XML", + yaml: "YAML", + yml: "YAML", + zsh: "Shell", +}; + +function normalizeLanguageLabel(infoString: string): string | null { + const languageToken = infoString.trim().split(/\s+/)[0]?.trim().toLowerCase(); + if (!languageToken) { + return null; + } + + const knownLabel = CODE_LANGUAGE_LABELS[languageToken]; + if (knownLabel) { + return knownLabel; + } + + const cleaned = languageToken.replace(/[^a-z0-9#+.-]/gi, ""); + if (!cleaned || !/[a-z]/i.test(cleaned)) { + return null; + } + + return cleaned + .split(/[-_.]+/) + .filter((segment) => segment.length > 0) + .map((segment) => `${segment.slice(0, 1).toUpperCase()}${segment.slice(1)}`) + .join(" "); +} + +function buildCodeBlockPlaceholder(infoString: string): string { + const languageLabel = normalizeLanguageLabel(infoString); + if (!languageLabel) { + return CODE_BLOCK_SUFFIX; + } + + return `${languageLabel} ${CODE_BLOCK_SUFFIX}`; +} + +function hasSpeakableText(value: string): boolean { + return value.replace(/[^\p{L}\p{N}]+/gu, "").length > 0; +} + +export function sanitizeAssistantMessageForTts(text: string): string { + let sanitized = text.trim(); + if (sanitized.length === 0) { + return ""; + } + + sanitized = sanitized.replace( + /(^|\n)(```|~~~)([^\n]*)\n[\s\S]*?\n\2(?=\n|$)/g, + (_match, leadingBoundary: string, _fence: string, infoString: string) => + `${leadingBoundary}${buildCodeBlockPlaceholder(infoString)}\n`, + ); + + sanitized = sanitized.replace(/!\[([^\]]*)\]\((?:[^()\\]|\\.)+\)/g, "$1"); + sanitized = sanitized.replace(/\[([^\]]+)\]\((?:[^()\\]|\\.)+\)/g, "$1"); + sanitized = sanitized.replace(/\s]+>/g, ""); + sanitized = sanitized.replace(/https?:\/\/\S+/g, ""); + sanitized = sanitized.replace(/`([^`]+)`/g, "$1"); + sanitized = sanitized.replace(/^#{1,6}\s+/gm, ""); + sanitized = sanitized.replace(/^>\s?/gm, ""); + sanitized = sanitized.replace(/^[-*+]\s+/gm, ""); + sanitized = sanitized.replace(/^\d+\.\s+/gm, ""); + sanitized = sanitized.replace(/^[-*_]{3,}$/gm, ""); + sanitized = sanitized.replace(/[*_~]/g, ""); + sanitized = sanitized.replace(/<\/?[^>]+>/g, ""); + sanitized = sanitized.replace(/[ \t]*\|[ \t]*/g, " "); + sanitized = sanitized.replace(/[ \t]+\n/g, "\n"); + sanitized = sanitized.replace(/\n{3,}/g, "\n\n"); + sanitized = sanitized.replace(/[ \t]{2,}/g, " "); + sanitized = sanitized.trim(); + + return hasSpeakableText(sanitized) ? sanitized : ""; +} diff --git a/apps/web/src/features/tts/tts.test.ts b/apps/web/src/features/tts/tts.test.ts new file mode 100644 index 000000000..357be5360 --- /dev/null +++ b/apps/web/src/features/tts/tts.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getSnapshot, isSupported, stopPlayback, toggleMessagePlayback } from "./tts"; + +class MockSpeechSynthesisUtterance { + readonly text: string; + lang = ""; + voice: SpeechSynthesisVoice | null = null; + onend: (() => void) | null = null; + onerror: ((event: { error?: string }) => void) | null = null; + + constructor(text: string) { + this.text = text; + } +} + +interface SpeechMockState { + readonly speakCalls: MockSpeechSynthesisUtterance[]; + cancelCount: number; +} + +const speechSynthesisDescriptor = Object.getOwnPropertyDescriptor(globalThis, "speechSynthesis"); +const utteranceDescriptor = Object.getOwnPropertyDescriptor(globalThis, "SpeechSynthesisUtterance"); + +function restoreSpeechGlobals(): void { + if (speechSynthesisDescriptor) { + Object.defineProperty(globalThis, "speechSynthesis", speechSynthesisDescriptor); + } else { + Reflect.deleteProperty(globalThis, "speechSynthesis"); + } + + if (utteranceDescriptor) { + Object.defineProperty(globalThis, "SpeechSynthesisUtterance", utteranceDescriptor); + } else { + Reflect.deleteProperty(globalThis, "SpeechSynthesisUtterance"); + } +} + +function installSpeechMock(): SpeechMockState { + const state: SpeechMockState = { + speakCalls: [], + cancelCount: 0, + }; + + Object.defineProperty(globalThis, "speechSynthesis", { + configurable: true, + value: { + cancel: vi.fn(() => { + state.cancelCount += 1; + }), + getVoices: vi.fn(() => []), + speak: vi.fn((utterance: SpeechSynthesisUtterance) => { + state.speakCalls.push(utterance as unknown as MockSpeechSynthesisUtterance); + }), + } satisfies Partial, + }); + Object.defineProperty(globalThis, "SpeechSynthesisUtterance", { + configurable: true, + value: MockSpeechSynthesisUtterance as unknown as typeof SpeechSynthesisUtterance, + }); + + return state; +} + +describe("tts", () => { + beforeEach(() => { + restoreSpeechGlobals(); + stopPlayback(); + }); + + afterEach(() => { + restoreSpeechGlobals(); + stopPlayback(); + }); + + it("reports unsupported state when native speech synthesis is unavailable", () => { + expect(isSupported()).toBe(false); + expect(getSnapshot().status).toBe("unsupported"); + }); + + it("starts playback for a message and exposes the active snapshot", () => { + const speech = installSpeechMock(); + + toggleMessagePlayback({ + messageId: "message-1", + text: "Read this response aloud.", + }); + + expect(speech.speakCalls).toHaveLength(1); + expect(speech.speakCalls[0]?.text).toBe("Read this response aloud."); + expect(getSnapshot()).toMatchObject({ + status: "playing", + activeMessageId: "message-1", + provider: "native", + }); + }); + + it("stops playback when toggling the active message again", () => { + const speech = installSpeechMock(); + + toggleMessagePlayback({ + messageId: "message-1", + text: "Read this response aloud.", + }); + toggleMessagePlayback({ + messageId: "message-1", + text: "Read this response aloud.", + }); + + expect(speech.cancelCount).toBe(2); + expect(getSnapshot().status).toBe("idle"); + expect(getSnapshot().activeMessageId).toBeNull(); + }); + + it("switches playback to a different message and ignores stale completion callbacks", () => { + const speech = installSpeechMock(); + + toggleMessagePlayback({ + messageId: "message-1", + text: "First message.", + }); + const firstUtterance = speech.speakCalls[0]!; + + toggleMessagePlayback({ + messageId: "message-2", + text: "Second message.", + }); + + firstUtterance.onend?.(); + + expect(speech.cancelCount).toBe(2); + expect(speech.speakCalls).toHaveLength(2); + expect(getSnapshot()).toMatchObject({ + status: "playing", + activeMessageId: "message-2", + }); + }); +}); diff --git a/apps/web/src/features/tts/tts.ts b/apps/web/src/features/tts/tts.ts new file mode 100644 index 000000000..174dc7e9b --- /dev/null +++ b/apps/web/src/features/tts/tts.ts @@ -0,0 +1,147 @@ +import { createNativeSpeechController } from "./nativeSpeechSynthesis"; + +export type TtsProviderKind = "native" | "openai" | "elevenlabs"; +export type TtsPlaybackStatus = "idle" | "playing" | "unsupported" | "error"; + +export interface TtsSnapshot { + readonly status: TtsPlaybackStatus; + readonly activeMessageId: string | null; + readonly provider: TtsProviderKind; + readonly errorMessage?: string; +} + +export interface SpeakMessageInput { + readonly messageId: string; + readonly text: string; + readonly lang?: string; +} + +const listeners = new Set<() => void>(); + +let playbackGeneration = 0; +let snapshot: TtsSnapshot = { + status: "idle", + activeMessageId: null, + provider: "native", +}; + +function emitChange(): void { + for (const listener of listeners) { + listener(); + } +} + +function setSnapshot(nextSnapshot: TtsSnapshot): void { + snapshot = nextSnapshot; + emitChange(); +} + +function buildIdleSnapshot(): TtsSnapshot { + return { + status: "idle", + activeMessageId: null, + provider: "native", + }; +} + +function asError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === "string" ? error : "Speech synthesis failed."); +} + +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function isSupported(): boolean { + return createNativeSpeechController().isSupported(); +} + +export function getSnapshot(): TtsSnapshot { + if (!isSupported() && snapshot.status === "idle") { + return { + ...snapshot, + status: "unsupported", + }; + } + + return snapshot; +} + +export function stopPlayback(): void { + playbackGeneration += 1; + createNativeSpeechController().stop(); + setSnapshot(buildIdleSnapshot()); +} + +export function toggleMessagePlayback(input: SpeakMessageInput): void { + const trimmedText = input.text.trim(); + if (trimmedText.length === 0) { + return; + } + + const controller = createNativeSpeechController(); + if (!controller.isSupported()) { + setSnapshot({ + status: "unsupported", + activeMessageId: null, + provider: "native", + }); + return; + } + + if (snapshot.status === "playing" && snapshot.activeMessageId === input.messageId) { + stopPlayback(); + return; + } + + const nextGeneration = playbackGeneration + 1; + playbackGeneration = nextGeneration; + controller.stop(); + setSnapshot({ + status: "playing", + activeMessageId: input.messageId, + provider: "native", + }); + + try { + controller.speak({ + text: trimmedText, + ...(input.lang ? { lang: input.lang } : {}), + onEnd: () => { + if (playbackGeneration !== nextGeneration) { + return; + } + setSnapshot(buildIdleSnapshot()); + }, + onError: (error) => { + if (playbackGeneration !== nextGeneration) { + return; + } + setSnapshot({ + status: "error", + activeMessageId: null, + provider: "native", + errorMessage: error.message, + }); + }, + }); + } catch (error) { + if (playbackGeneration !== nextGeneration) { + return; + } + const resolvedError = asError(error); + setSnapshot({ + status: "error", + activeMessageId: null, + provider: "native", + errorMessage: resolvedError.message, + }); + } +} diff --git a/apps/web/src/features/tts/useMessageTts.ts b/apps/web/src/features/tts/useMessageTts.ts new file mode 100644 index 000000000..14758648b --- /dev/null +++ b/apps/web/src/features/tts/useMessageTts.ts @@ -0,0 +1,28 @@ +import { useSyncExternalStore } from "react"; +import { sanitizeAssistantMessageForTts } from "./sanitizeTtsText"; +import { getSnapshot, isSupported, subscribe, toggleMessagePlayback } from "./tts"; + +export function useMessageTts(messageId: string, text: string) { + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const sanitizedText = sanitizeAssistantMessageForTts(text); + const supported = isSupported(); + const isPlaying = snapshot.status === "playing" && snapshot.activeMessageId === messageId; + const canPlay = supported && sanitizedText.length > 0; + + return { + supported, + isPlaying, + canPlay, + title: isPlaying ? "Stop playback" : "Play message", + toggle() { + if (!canPlay) { + return; + } + + toggleMessagePlayback({ + messageId, + text: sanitizedText, + }); + }, + } as const; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c9..f0827501e 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,17 +5,22 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@xterm/xterm/css/xterm.css"; import "./index.css"; +import "./overrides.css"; import { isElectron } from "./env"; +import { registerServiceWorker } from "./pwa"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; +import { applyRuntimeBranding } from "./runtimeBranding"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); +applyRuntimeBranding(document, window.location.hostname); document.title = APP_DISPLAY_NAME; +void registerServiceWorker(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/notifications/client.ts b/apps/web/src/notifications/client.ts new file mode 100644 index 000000000..031ee4ad8 --- /dev/null +++ b/apps/web/src/notifications/client.ts @@ -0,0 +1,108 @@ +import { type WebPushConfigResponse } from "./types"; + +const WEB_PUSH_CONFIG_PATH = "/api/web-push/config"; +const WEB_PUSH_SUBSCRIPTION_PATH = "/api/web-push/subscription"; + +function assertWebPushConfigResponse(value: unknown): WebPushConfigResponse { + if (typeof value !== "object" || value === null || !("enabled" in value)) { + throw new Error("Invalid web push config response."); + } + + const candidate = value as { + readonly enabled: unknown; + readonly publicKey?: unknown; + readonly serviceWorkerPath?: unknown; + readonly manifestPath?: unknown; + }; + + if (candidate.enabled === false) { + return { enabled: false }; + } + + if ( + candidate.enabled === true && + typeof candidate.publicKey === "string" && + typeof candidate.serviceWorkerPath === "string" && + typeof candidate.manifestPath === "string" + ) { + return { + enabled: true, + publicKey: candidate.publicKey, + serviceWorkerPath: candidate.serviceWorkerPath, + manifestPath: candidate.manifestPath, + }; + } + + throw new Error("Invalid web push config response."); +} + +async function readJsonResponse(response: Response): Promise { + const text = await response.text(); + if (text.length === 0) { + return null; + } + return JSON.parse(text); +} + +async function assertOk(response: Response): Promise { + if (response.ok) { + return; + } + + const message = await response.text(); + throw new Error(message || `Request failed with status ${response.status}`); +} + +export async function fetchWebPushConfig(): Promise { + const response = await fetch(WEB_PUSH_CONFIG_PATH, { + cache: "no-store", + }); + await assertOk(response); + return assertWebPushConfigResponse(await readJsonResponse(response)); +} + +export async function putSubscription(input: { + readonly subscription: PushSubscriptionJSON; + readonly appVersion: string; +}): Promise { + const response = await fetch(WEB_PUSH_SUBSCRIPTION_PATH, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + subscription: input.subscription, + userAgent: navigator.userAgent, + appVersion: input.appVersion, + }), + }); + await assertOk(response); +} + +export async function deleteSubscription(input: { readonly endpoint: string }): Promise { + const response = await fetch(WEB_PUSH_SUBSCRIPTION_PATH, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + subscription: { + endpoint: input.endpoint, + }, + }), + }); + await assertOk(response); +} + +export function decodeBase64UrlPublicKey(input: string): Uint8Array { + const padded = `${input}${"=".repeat((4 - (input.length % 4 || 4)) % 4)}`; + const base64 = padded.replaceAll("-", "+").replaceAll("_", "/"); + const decoded = atob(base64); + const bytes = new Uint8Array(decoded.length); + + for (let index = 0; index < decoded.length; index += 1) { + bytes[index] = decoded.charCodeAt(index); + } + + return bytes; +} diff --git a/apps/web/src/notifications/pushSupport.ts b/apps/web/src/notifications/pushSupport.ts new file mode 100644 index 000000000..bb989de6b --- /dev/null +++ b/apps/web/src/notifications/pushSupport.ts @@ -0,0 +1,17 @@ +import { isElectron } from "../env"; + +export function isPushSupported(): boolean { + if (typeof window === "undefined" || typeof navigator === "undefined") { + return false; + } + + if (isElectron || !window.isSecureContext) { + return false; + } + + return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; +} + +export function canRequestNotificationPermission(): boolean { + return isPushSupported() && Notification.permission === "default"; +} diff --git a/apps/web/src/notifications/registerServiceWorker.ts b/apps/web/src/notifications/registerServiceWorker.ts new file mode 100644 index 000000000..f70bcedf2 --- /dev/null +++ b/apps/web/src/notifications/registerServiceWorker.ts @@ -0,0 +1,11 @@ +export const PUSH_SERVICE_WORKER_PATH = "/service-worker.js"; +export const PUSH_SERVICE_WORKER_SCOPE = "/"; + +export async function registerPushServiceWorker(): Promise { + const registration = await navigator.serviceWorker.register(PUSH_SERVICE_WORKER_PATH, { + scope: PUSH_SERVICE_WORKER_SCOPE, + }); + + await navigator.serviceWorker.ready; + return registration; +} diff --git a/apps/web/src/notifications/types.ts b/apps/web/src/notifications/types.ts new file mode 100644 index 000000000..065be4e69 --- /dev/null +++ b/apps/web/src/notifications/types.ts @@ -0,0 +1,12 @@ +export interface WebPushConfigDisabledResponse { + readonly enabled: false; +} + +export interface WebPushConfigEnabledResponse { + readonly enabled: true; + readonly publicKey: string; + readonly serviceWorkerPath: string; + readonly manifestPath: string; +} + +export type WebPushConfigResponse = WebPushConfigDisabledResponse | WebPushConfigEnabledResponse; diff --git a/apps/web/src/notifications/usePushNotifications.ts b/apps/web/src/notifications/usePushNotifications.ts new file mode 100644 index 000000000..da5b67b05 --- /dev/null +++ b/apps/web/src/notifications/usePushNotifications.ts @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useState } from "react"; + +import { APP_VERSION } from "../branding"; +import { useAppSettings } from "../appSettings"; +import { + decodeBase64UrlPublicKey, + deleteSubscription, + fetchWebPushConfig, + putSubscription, +} from "./client"; +import { canRequestNotificationPermission, isPushSupported } from "./pushSupport"; +import { registerPushServiceWorker } from "./registerServiceWorker"; + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return fallback; +} + +function asSubscriptionJson(subscription: PushSubscription): PushSubscriptionJSON | null { + const json = subscription.toJSON(); + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + return null; + } + return json; +} + +export function usePushNotifications() { + const { settings, updateSettings } = useAppSettings(); + const supported = isPushSupported(); + const [serverEnabled, setServerEnabled] = useState(false); + const [subscribed, setSubscribed] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const permission = supported ? Notification.permission : "unsupported"; + + const refreshSubscriptionState = useCallback(async () => { + if (!supported) { + setSubscribed(false); + return null; + } + + const registration = await registerPushServiceWorker(); + const subscription = await registration.pushManager.getSubscription(); + setSubscribed(subscription !== null); + return subscription; + }, [supported]); + + const loadServerConfig = useCallback(async () => { + const config = await fetchWebPushConfig(); + setServerEnabled(config.enabled); + return config; + }, []); + + const enable = useCallback(async () => { + if (!supported) { + setError("Push notifications are not supported in this browser."); + return; + } + + setBusy(true); + setError(null); + + try { + const config = await loadServerConfig(); + if (!config.enabled) { + updateSettings({ pushNotificationsEnabled: false }); + setSubscribed(false); + throw new Error("Web push notifications are not configured on this server."); + } + + const registration = await registerPushServiceWorker(); + const nextPermission = await Notification.requestPermission(); + if (nextPermission !== "granted") { + updateSettings({ pushNotificationsEnabled: false }); + setSubscribed(false); + throw new Error( + nextPermission === "denied" + ? "Notifications are blocked in your browser settings." + : "Notification permission was not granted.", + ); + } + + let subscription = await registration.pushManager.getSubscription(); + if (subscription === null) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: decodeBase64UrlPublicKey(config.publicKey) as BufferSource, + }); + } + + const subscriptionJson = asSubscriptionJson(subscription); + if (!subscriptionJson) { + throw new Error("The browser returned an incomplete push subscription."); + } + + await putSubscription({ + subscription: subscriptionJson, + appVersion: APP_VERSION, + }); + + updateSettings({ pushNotificationsEnabled: true }); + setSubscribed(true); + } catch (nextError) { + setError(errorMessage(nextError, "Unable to enable push notifications.")); + } finally { + setBusy(false); + } + }, [loadServerConfig, supported, updateSettings]); + + const disable = useCallback(async () => { + if (!supported) { + updateSettings({ pushNotificationsEnabled: false }); + setSubscribed(false); + return; + } + + setBusy(true); + setError(null); + + try { + const subscription = await refreshSubscriptionState(); + const endpoint = subscription?.endpoint ?? null; + + if (endpoint) { + await deleteSubscription({ endpoint }).catch(() => undefined); + } + + if (subscription) { + await subscription.unsubscribe().catch(() => false); + } + + updateSettings({ pushNotificationsEnabled: false }); + setSubscribed(false); + } catch (nextError) { + setError(errorMessage(nextError, "Unable to disable push notifications.")); + } finally { + setBusy(false); + } + }, [refreshSubscriptionState, supported, updateSettings]); + + const refreshIfNeeded = useCallback(async () => { + if (!supported) { + setSubscribed(false); + return; + } + + try { + const config = await loadServerConfig(); + if (!config.enabled) { + setSubscribed(false); + return; + } + + const subscription = await refreshSubscriptionState(); + if (!settings.pushNotificationsEnabled || Notification.permission !== "granted") { + return; + } + + if (!subscription) { + setSubscribed(false); + return; + } + + const subscriptionJson = asSubscriptionJson(subscription); + if (!subscriptionJson) { + setSubscribed(false); + return; + } + + await putSubscription({ + subscription: subscriptionJson, + appVersion: APP_VERSION, + }); + setSubscribed(true); + } catch (nextError) { + setError(errorMessage(nextError, "Unable to refresh push notification state.")); + } + }, [loadServerConfig, refreshSubscriptionState, settings.pushNotificationsEnabled, supported]); + + useEffect(() => { + if (!supported) { + setServerEnabled(false); + setSubscribed(false); + return; + } + + let active = true; + + void (async () => { + try { + const config = await fetchWebPushConfig(); + if (!active) { + return; + } + setServerEnabled(config.enabled); + + const subscription = await refreshSubscriptionState(); + if (!active) { + return; + } + setSubscribed(subscription !== null); + } catch (nextError) { + if (!active) { + return; + } + setError(errorMessage(nextError, "Unable to load push notification settings.")); + } + })(); + + return () => { + active = false; + }; + }, [refreshSubscriptionState, supported]); + + return { + supported, + serverEnabled, + permission, + subscribed, + busy, + error, + enable, + disable, + refreshIfNeeded, + canRequestPermission: canRequestNotificationPermission(), + locallyEnabled: settings.pushNotificationsEnabled, + } as const; +} diff --git a/apps/web/src/overrides.css b/apps/web/src/overrides.css new file mode 100644 index 000000000..4da9f53fa --- /dev/null +++ b/apps/web/src/overrides.css @@ -0,0 +1,86 @@ +/* Local, intentionally brittle UI tweaks that we want to keep isolated from upstream edits. */ + +[data-project-row] { + border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--foreground) 5%, transparent) 0%, + color-mix(in srgb, var(--foreground) 3%, transparent) 100% + ); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-white) 3%, transparent); +} + +[data-project-row]:hover, +[data-project-row]:focus-visible { + border-color: color-mix(in srgb, var(--foreground) 9%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--foreground) 7%, transparent) 0%, + color-mix(in srgb, var(--foreground) 4%, transparent) 100% + ); +} + +[data-project-threads] { + margin-top: 0.2rem; +} + +.dark [data-project-row] { + border-color: color-mix(in srgb, var(--color-white) 7%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-white) 6%, transparent) 0%, + color-mix(in srgb, var(--color-white) 3.5%, transparent) 100% + ); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-white) 4%, transparent); +} + +.dark [data-project-row]:hover, +.dark [data-project-row]:focus-visible { + border-color: color-mix(in srgb, var(--color-white) 10%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-white) 8%, transparent) 0%, + color-mix(in srgb, var(--color-white) 4.5%, transparent) 100% + ); +} + +:root[data-host-variant="t3-dev"] + .flex.min-w-0.flex-1.items-center.gap-1.ml-1.cursor-pointer + > span:last-child { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 0.1rem; + padding-inline: 0.42rem; + padding-block: 0.18rem; + background: color-mix(in srgb, var(--color-red-500) 12%, transparent); + color: transparent; + font-size: 0; + line-height: 1; + letter-spacing: 0; +} + +:root[data-host-variant="t3-dev"] + .flex.min-w-0.flex-1.items-center.gap-1.ml-1.cursor-pointer + > span:last-child::after { + content: "DEVELOP"; + color: color-mix(in srgb, var(--color-red-600) 82%, var(--foreground)); + font-size: 8px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.16em; + transform: translateY(0.5px); +} + +:root.dark[data-host-variant="t3-dev"] + .flex.min-w-0.flex-1.items-center.gap-1.ml-1.cursor-pointer + > span:last-child { + background: color-mix(in srgb, var(--color-red-500) 18%, transparent); +} + +:root.dark[data-host-variant="t3-dev"] + .flex.min-w-0.flex-1.items-center.gap-1.ml-1.cursor-pointer + > span:last-child::after { + color: color-mix(in srgb, var(--color-red-400) 88%, var(--foreground)); +} diff --git a/apps/web/src/pwa.test.ts b/apps/web/src/pwa.test.ts new file mode 100644 index 000000000..b5bbc28d9 --- /dev/null +++ b/apps/web/src/pwa.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { SERVICE_WORKER_SCOPE, shouldRegisterServiceWorker } from "./pwa"; + +describe("shouldRegisterServiceWorker", () => { + it("registers for regular browser builds over http", () => { + expect( + shouldRegisterServiceWorker({ + isElectron: false, + hasServiceWorkerApi: true, + protocol: "http:", + }), + ).toBe(true); + }); + + it("skips registration for Electron builds", () => { + expect( + shouldRegisterServiceWorker({ + isElectron: true, + hasServiceWorkerApi: true, + protocol: "https:", + }), + ).toBe(false); + }); + + it("skips registration when the browser has no service worker support", () => { + expect( + shouldRegisterServiceWorker({ + isElectron: false, + hasServiceWorkerApi: false, + protocol: "https:", + }), + ).toBe(false); + }); + + it("keeps the installed app scoped to the site root", () => { + expect(SERVICE_WORKER_SCOPE).toBe("/"); + }); +}); diff --git a/apps/web/src/pwa.ts b/apps/web/src/pwa.ts new file mode 100644 index 000000000..1425c38fa --- /dev/null +++ b/apps/web/src/pwa.ts @@ -0,0 +1,40 @@ +import { isElectron } from "./env"; +import { + PUSH_SERVICE_WORKER_PATH as SERVICE_WORKER_PATH, + PUSH_SERVICE_WORKER_SCOPE as SERVICE_WORKER_SCOPE, + registerPushServiceWorker, +} from "./notifications/registerServiceWorker"; + +export { SERVICE_WORKER_PATH, SERVICE_WORKER_SCOPE }; + +export function shouldRegisterServiceWorker(input: { + readonly isElectron: boolean; + readonly hasServiceWorkerApi: boolean; + readonly protocol: string; +}): boolean { + if (input.isElectron || !input.hasServiceWorkerApi) { + return false; + } + + return input.protocol === "http:" || input.protocol === "https:"; +} + +export async function registerServiceWorker(): Promise { + if ( + !shouldRegisterServiceWorker({ + isElectron, + hasServiceWorkerApi: "serviceWorker" in navigator, + protocol: window.location.protocol, + }) + ) { + return; + } + + try { + await registerPushServiceWorker(); + } catch (error) { + if (import.meta.env.DEV) { + console.warn("Service worker registration failed", error); + } + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82..3dff64241 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type OrchestrationThreadActivity } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -13,6 +13,8 @@ import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; +import { UserInputDebugPanel } from "../components/debug/UserInputDebugPanel"; +import { logUserInputDebug } from "../debug/userInputDebug"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; @@ -24,6 +26,7 @@ import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { usePushNotifications } from "../notifications/usePushNotifications"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -52,13 +55,25 @@ function RootRouteView() { + + ); } +function PushNotificationsBootstrap() { + const { refreshIfNeeded } = usePushNotifications(); + + useEffect(() => { + void refreshIfNeeded(); + }, [refreshIfNeeded]); + + return null; +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); @@ -207,6 +222,41 @@ function EventRouter() { ); const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { + if (event.type === "thread.user-input-response-requested") { + logUserInputDebug({ + level: "info", + stage: "domain-event", + message: "Observed thread.user-input-response-requested", + threadId: event.payload.threadId, + requestId: event.payload.requestId, + ...withDebugDetail(stringifyDebugDetail(event.payload.answers)), + }); + } + if (event.type === "thread.activity-appended") { + const activity = event.payload.activity; + if (isInterestingUserInputActivity(activity)) { + logUserInputDebug({ + level: activity.kind === "provider.user-input.respond.failed" ? "error" : "success", + stage: "domain-activity", + message: `Observed ${activity.kind}`, + threadId: event.payload.threadId, + requestId: requestIdFromActivity(activity), + ...withDebugDetail( + stringifyDebugDetail({ + summary: activity.summary, + payload: activity.payload, + }), + ), + }); + } + if (activity.kind === "provider.user-input.respond.failed") { + toastManager.add({ + type: "error", + title: "Question expired", + description: describePendingUserInputFailure(activity), + }); + } + } if (event.sequence <= latestSequence) { return; } @@ -321,6 +371,61 @@ function EventRouter() { return null; } +function isInterestingUserInputActivity(activity: OrchestrationThreadActivity): boolean { + return ( + activity.kind === "user-input.requested" || + activity.kind === "user-input.resolved" || + activity.kind === "provider.user-input.respond.failed" + ); +} + +function requestIdFromActivity(activity: OrchestrationThreadActivity): string | null { + const payload = activity.payload; + if (!payload || typeof payload !== "object") { + return null; + } + const requestId = (payload as Record).requestId; + return typeof requestId === "string" ? requestId : null; +} + +function stringifyDebugDetail(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function withDebugDetail(detail: string | undefined): { detail: string } | undefined { + return typeof detail === "string" ? { detail } : undefined; +} + +function describePendingUserInputFailure(activity: OrchestrationThreadActivity): string { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const detail = typeof payload?.detail === "string" ? payload.detail : null; + if (!detail) { + return "This question could not be answered."; + } + const normalized = detail.toLowerCase(); + if ( + normalized.includes("unknown pending user input request") || + normalized.includes("belongs to a previous provider session") || + normalized.includes("expired after the session restarted") + ) { + return ( + "This question came from an earlier session and can no longer be answered. " + + "Ask the agent to re-ask it." + ); + } + return detail; +} + function DesktopProjectBootstrap() { // Desktop hydration runs through EventRouter project + orchestration sync. return null; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..038b12c5a 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -21,6 +21,7 @@ import { import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; import { SidebarInset } from "~/components/ui/sidebar"; +import { usePushNotifications } from "../notifications/usePushNotifications"; const THEME_OPTIONS = [ { @@ -95,6 +96,7 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); + const pushNotifications = usePushNotifications(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); @@ -111,6 +113,21 @@ function SettingsRouteView() { const codexHomePath = settings.codexHomePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const notificationsStatus = !pushNotifications.supported + ? "This browser does not support PWA push notifications." + : !pushNotifications.serverEnabled + ? "Push notifications are not configured on this server." + : pushNotifications.permission === "denied" + ? "Browser notifications are currently blocked." + : pushNotifications.subscribed + ? "Notifications are enabled for this device." + : pushNotifications.locallyEnabled && pushNotifications.permission === "granted" + ? "Permission is granted and the app is ready to resubscribe." + : pushNotifications.canRequestPermission + ? "Notifications are ready to enable." + : pushNotifications.permission === "granted" + ? "Browser permission is granted, but notifications are off in T3 Code." + : "Notifications are not enabled yet."; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -588,6 +605,74 @@ function SettingsRouteView() { ) : null} +
+
+

Notifications

+

+ Enable web push notifications for assistant completions, approvals, and user input + requests. Notification clicks deep-link back into the matching thread. +

+
+ +
+
+

Status

+

{notificationsStatus}

+ {pushNotifications.error ? ( +

{pushNotifications.error}

+ ) : null} +
+ +
+ + + +
+ + {pushNotifications.permission === "denied" ? ( +

+ Re-enable notifications from your browser’s site settings, then come back here + and turn them on again. +

+ ) : null} + + {settings.pushNotificationsEnabled !== defaults.pushNotificationsEnabled ? ( +
+ +
+ ) : null} +
+
+

Keybindings

diff --git a/apps/web/src/runtimeBranding.test.ts b/apps/web/src/runtimeBranding.test.ts new file mode 100644 index 000000000..07bb1abf6 --- /dev/null +++ b/apps/web/src/runtimeBranding.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { T3_DEV_HOST_VARIANT, resolveRuntimeBranding } from "./runtimeBranding"; + +describe("resolveRuntimeBranding", () => { + it("uses the red PWA assets on t3-dev", () => { + expect(resolveRuntimeBranding("t3-dev.claude.do")).toEqual({ + hostVariant: T3_DEV_HOST_VARIANT, + manifestPath: "/manifest-t3-dev.webmanifest", + appleTouchIconPath: "/apple-touch-icon-dev.png", + faviconPath: "/favicon-dev.ico", + }); + }); + + it("matches the dev host case-insensitively", () => { + expect(resolveRuntimeBranding("T3-DEV.CLAUDE.DO").hostVariant).toBe(T3_DEV_HOST_VARIANT); + }); + + it("leaves other hosts unchanged", () => { + expect(resolveRuntimeBranding("t3.claude.do")).toEqual({}); + expect(resolveRuntimeBranding("localhost")).toEqual({}); + }); +}); diff --git a/apps/web/src/runtimeBranding.ts b/apps/web/src/runtimeBranding.ts new file mode 100644 index 000000000..132e6efdc --- /dev/null +++ b/apps/web/src/runtimeBranding.ts @@ -0,0 +1,51 @@ +export const T3_DEV_HOSTNAME = "t3-dev.claude.do"; +export const T3_DEV_HOST_VARIANT = "t3-dev"; + +export interface RuntimeBranding { + readonly hostVariant?: string; + readonly manifestPath?: string; + readonly appleTouchIconPath?: string; + readonly faviconPath?: string; +} + +export function resolveRuntimeBranding(hostname: string): RuntimeBranding { + if (hostname.trim().toLowerCase() !== T3_DEV_HOSTNAME) { + return {}; + } + + return { + hostVariant: T3_DEV_HOST_VARIANT, + manifestPath: "/manifest-t3-dev.webmanifest", + appleTouchIconPath: "/apple-touch-icon-dev.png", + faviconPath: "/favicon-dev.ico", + }; +} + +export function applyRuntimeBranding(doc: Document, hostname: string): void { + const branding = resolveRuntimeBranding(hostname); + + if (branding.hostVariant) { + doc.documentElement.dataset.hostVariant = branding.hostVariant; + } else { + delete doc.documentElement.dataset.hostVariant; + } + + if (branding.manifestPath) { + setLinkHref(doc, 'link[rel="manifest"]', branding.manifestPath); + } + + if (branding.appleTouchIconPath) { + setLinkHref(doc, 'link[rel="apple-touch-icon"]', branding.appleTouchIconPath); + } + + if (branding.faviconPath) { + setLinkHref(doc, 'link[rel="icon"]', branding.faviconPath); + setLinkHref(doc, 'link[rel="shortcut icon"]', branding.faviconPath); + } +} + +function setLinkHref(doc: Document, selector: string, href: string): void { + for (const link of doc.querySelectorAll(selector)) { + link.setAttribute("href", href); + } +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 74ba3a814..e0fc5e5e5 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -220,6 +220,48 @@ describe("derivePendingUserInputs", () => { }, ]); }); + + it("clears stale pending user input when the provider reports an expired request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open-stale", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-stale", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + makeActivity({ + id: "user-input-stale-failed", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + tone: "error", + payload: { + requestId: "req-user-input-stale", + detail: + "This question came from an earlier provider session and expired after the session restarted. Please ask the agent to re-ask the question.", + }, + }), + ]; + + expect(derivePendingUserInputs(activities)).toEqual([]); + }); }); describe("deriveActivePlanState", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2..b51510023 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -156,6 +156,18 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ } } +function isStalePendingUserInputDetail(detail: string | undefined): boolean { + if (!detail) { + return false; + } + const normalized = detail.toLowerCase(); + return ( + normalized.includes("unknown pending user input request") || + normalized.includes("belongs to a previous provider session") || + normalized.includes("expired after the session restarted") + ); +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { @@ -292,6 +304,16 @@ export function derivePendingUserInputs( if (activity.kind === "user-input.resolved" && requestId) { openByRequestId.delete(requestId); + continue; + } + + const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; + if ( + activity.kind === "provider.user-input.respond.failed" && + requestId && + isStalePendingUserInputDetail(detail) + ) { + openByRequestId.delete(requestId); } } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..325935a07 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -12,9 +12,10 @@ import { resolveModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; +import { resolveHttpOriginFromWebSocketUrl } from "./wsUrl"; +import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; -import { Debouncer } from "@tanstack/react-pacer"; // ── State ──────────────────────────────────────────────────────────── @@ -213,23 +214,10 @@ function inferProviderForThreadModel(input: { function resolveWsHttpOrigin(): string { if (typeof window === "undefined") return ""; - const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); - const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; - const wsCandidate = - typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 - ? bridgeWsUrl - : typeof envWsUrl === "string" && envWsUrl.length > 0 - ? envWsUrl - : null; - if (!wsCandidate) return window.location.origin; - try { - const wsUrl = new URL(wsCandidate); - const protocol = - wsUrl.protocol === "wss:" ? "https:" : wsUrl.protocol === "ws:" ? "http:" : wsUrl.protocol; - return `${protocol}//${wsUrl.host}`; - } catch { - return window.location.origin; - } + return resolveHttpOriginFromWebSocketUrl({ + bridgeUrl: window.desktopBridge?.getWsUrl?.(), + envUrl: import.meta.env.VITE_WS_URL as string | undefined, + }); } function toAttachmentPreviewUrl(rawUrl: string): string { diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 46c74d909..43b0756de 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; import { Result, Schema } from "effect"; +import { resolveWebSocketUrl } from "./wsUrl"; type PushListener = (message: WsPushMessage) => void; @@ -62,13 +63,7 @@ export class WsTransport { constructor(url?: string) { const bridgeUrl = window.desktopBridge?.getWsUrl(); const envUrl = import.meta.env.VITE_WS_URL as string | undefined; - this.url = - url ?? - (bridgeUrl && bridgeUrl.length > 0 - ? bridgeUrl - : envUrl && envUrl.length > 0 - ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); + this.url = url ?? resolveWebSocketUrl({ bridgeUrl, envUrl }); this.connect(); } diff --git a/apps/web/src/wsUrl.test.ts b/apps/web/src/wsUrl.test.ts new file mode 100644 index 000000000..06b5528a2 --- /dev/null +++ b/apps/web/src/wsUrl.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resolveHttpOriginFromWebSocketUrl, resolveWebSocketUrl } from "./wsUrl"; + +const originalWindow = globalThis.window; + +function setWindowLocation(location: Partial) { + const nextLocation = { + protocol: "http:", + host: "localhost:3020", + origin: "http://localhost:3020", + ...location, + }; + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: nextLocation, + }, + }); +} + +beforeEach(() => { + setWindowLocation({ + protocol: "http:", + host: "localhost:3020", + origin: "http://localhost:3020", + }); +}); + +afterEach(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); +}); + +describe("resolveWebSocketUrl", () => { + it("defaults to wss on https pages", () => { + setWindowLocation({ + protocol: "https:", + host: "t3.claude.do", + origin: "https://t3.claude.do", + }); + + expect(resolveWebSocketUrl()).toBe("wss://t3.claude.do"); + }); + + it("defaults to ws on http pages", () => { + expect(resolveWebSocketUrl()).toBe("ws://localhost:3020"); + }); + + it("prefers the env override over the page origin", () => { + expect(resolveWebSocketUrl({ envUrl: "ws://127.0.0.1:3773" })).toBe("ws://127.0.0.1:3773"); + }); + + it("prefers the desktop bridge override over the env override", () => { + expect( + resolveWebSocketUrl({ + bridgeUrl: "ws://127.0.0.1:4444/?token=secret", + envUrl: "ws://127.0.0.1:3773", + }), + ).toBe("ws://127.0.0.1:4444/?token=secret"); + }); +}); + +describe("resolveHttpOriginFromWebSocketUrl", () => { + it("maps wss websocket URLs back to https origins", () => { + setWindowLocation({ + protocol: "https:", + host: "t3.claude.do", + origin: "https://t3.claude.do", + }); + + expect(resolveHttpOriginFromWebSocketUrl()).toBe("https://t3.claude.do"); + }); + + it("drops query parameters from explicit websocket URLs", () => { + expect( + resolveHttpOriginFromWebSocketUrl({ + bridgeUrl: "wss://t3.claude.do/socket?token=secret", + }), + ).toBe("https://t3.claude.do"); + }); + + it("falls back to the page origin when the websocket URL cannot be parsed", () => { + expect( + resolveHttpOriginFromWebSocketUrl({ + envUrl: "not a valid url", + }), + ).toBe("http://localhost:3020"); + }); +}); diff --git a/apps/web/src/wsUrl.ts b/apps/web/src/wsUrl.ts new file mode 100644 index 000000000..f02361b19 --- /dev/null +++ b/apps/web/src/wsUrl.ts @@ -0,0 +1,39 @@ +function resolvePageWebSocketUrl(location: Location): string { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${location.host}`; +} + +export function resolveWebSocketUrl(input?: { + readonly bridgeUrl?: string | null | undefined; + readonly envUrl?: string | null | undefined; + readonly location?: Location; +}): string { + const bridgeUrl = input?.bridgeUrl; + if (typeof bridgeUrl === "string" && bridgeUrl.length > 0) { + return bridgeUrl; + } + + const envUrl = input?.envUrl; + if (typeof envUrl === "string" && envUrl.length > 0) { + return envUrl; + } + + const location = input?.location ?? window.location; + return resolvePageWebSocketUrl(location); +} + +export function resolveHttpOriginFromWebSocketUrl(input?: { + readonly bridgeUrl?: string | null | undefined; + readonly envUrl?: string | null | undefined; + readonly location?: Location; +}): string { + const wsUrl = resolveWebSocketUrl(input); + const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); + + try { + return new URL(httpUrl).origin; + } catch { + const location = input?.location ?? window.location; + return location.origin; + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 56b138d33..be5eb21fb 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,11 +2,42 @@ import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import type { HmrOptions } from "vite"; import { defineConfig } from "vite"; import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); +const devHost = process.env.VITE_DEV_HOST ?? "127.0.0.1"; +const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? "") + .split(",") + .map((host) => host.trim()) + .filter(Boolean); + +const hmrProtocol = process.env.VITE_HMR_PROTOCOL ?? "ws"; +const hmrHost = process.env.VITE_HMR_HOST ?? "localhost"; +const hmrPath = process.env.VITE_HMR_PATH; +const hmrClientPort = process.env.VITE_HMR_CLIENT_PORT + ? Number(process.env.VITE_HMR_CLIENT_PORT) + : undefined; +const hmrPort = process.env.VITE_HMR_PORT ? Number(process.env.VITE_HMR_PORT) : undefined; const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); +const webPushProxyTarget = (() => { + const wsUrl = process.env.VITE_WS_URL?.trim(); + if (!wsUrl) { + return undefined; + } + + try { + const parsed = new URL(wsUrl); + parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:"; + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.origin; + } catch { + return undefined; + } +})(); const buildSourcemap = sourcemapEnv === "0" || sourcemapEnv === "false" @@ -15,6 +46,16 @@ const buildSourcemap = ? "hidden" : true; +const hmrConfig: HmrOptions = { + // Keep defaults friendly for localhost, but allow a real public host when + // the dev UI is reverse-proxied through Caddy/Cloudflare. + protocol: hmrProtocol, + host: hmrHost, + ...(hmrPath ? { path: hmrPath } : {}), + ...(hmrClientPort !== undefined ? { clientPort: hmrClientPort } : {}), + ...(hmrPort !== undefined ? { port: hmrPort } : {}), +}; + export default defineConfig({ plugins: [ tanstackRouter(), @@ -33,7 +74,7 @@ export default defineConfig({ include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], }, define: { - // In dev mode, tell the web app where the WebSocket server lives + // In dev mode, tell the web app where the WebSocket server lives. "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""), "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), }, @@ -41,15 +82,22 @@ export default defineConfig({ tsconfigPaths: true, }, server: { + host: devHost, port, strictPort: true, - hmr: { - // Explicit config so Vite's HMR WebSocket connects reliably - // inside Electron's BrowserWindow. Vite 8 uses console.debug for - // connection logs — enable "Verbose" in DevTools to see them. - protocol: "ws", - host: "localhost", - }, + ...(allowedHosts.length > 0 ? { allowedHosts } : {}), + hmr: hmrConfig, + ...(webPushProxyTarget + ? { + proxy: { + "/api/web-push": { + target: webPushProxyTarget, + changeOrigin: true, + xfwd: true, + }, + }, + } + : {}), }, build: { outDir: "dist", diff --git a/bun.lock b/bun.lock index b8e36149f..b3bb5838a 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", + "web-push": "^3.6.7", "ws": "^8.18.0", }, "devDependencies": { @@ -64,6 +65,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/web-push": "^3.6.4", "@types/ws": "^8.5.13", "tsdown": "catalog:", "typescript": "catalog:", @@ -780,6 +782,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], @@ -828,6 +832,8 @@ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -846,6 +852,8 @@ "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], @@ -872,6 +880,8 @@ "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], @@ -882,6 +892,8 @@ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], @@ -1010,6 +1022,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], @@ -1168,8 +1182,14 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1232,6 +1252,10 @@ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -1396,6 +1420,8 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -1592,6 +1618,10 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1834,6 +1864,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], diff --git a/enhancement.MD b/enhancement.MD new file mode 100644 index 000000000..794091128 --- /dev/null +++ b/enhancement.MD @@ -0,0 +1,98 @@ +# Enhancement Ledger + +This file tracks every project-specific change we carry on top of `upstream`. + +Goal: if something breaks after an upstream sync, deploy, or refactor, we should be able to scan this file and quickly answer: + +- what we changed +- why we changed it +- which files and runtime surfaces are involved +- what symptoms to look for if it regresses +- how to roll it back or verify it + +## How To Use This File + +Add one entry for every fork-specific enhancement, behavior change, deployment customization, or operational deviation from `upstream`. + +Update the matching entry when: + +- the enhancement changes shape +- more files become involved +- the deploy or rollback process changes +- we discover new failure symptoms or verification steps + +Prefer one entry per user-visible change or operational customization. If a later change extends an earlier one, update the existing entry and add a short dated note. + +## Entry Template + +Copy this block for new entries: + +```md +## + +- Status: active | deprecated | rolled back +- First added: YYYY-MM-DD +- Last updated: YYYY-MM-DD +- Owners: +- Upstream impact: none | low | medium | high +- Areas: +- Why this exists: +- Files: + - `path/to/file` + - `path/to/another-file` +- Runtime touchpoints: + - +- If this breaks, look for: + - + - +- Verify with: + - + - +- Rollback notes: + - +- Notes: + - YYYY-MM-DD: +``` + +## Active Enhancements + +## Root-Scoped PWA Install Behavior + +- Status: active +- First added: 2026-03-16 +- Last updated: 2026-03-16 +- Owners: T3 Code fork +- Upstream impact: medium +- Areas: web app install metadata, iPhone Home Screen behavior, offline/app-shell navigation +- Why this exists: iPhone-installed web app sessions were opening chat routes as external web views instead of keeping navigation inside the installed app. +- Files: + - `apps/web/index.html` + - `apps/web/public/manifest.webmanifest` + - `apps/web/public/sw.js` + - `apps/web/src/main.tsx` + - `apps/web/src/pwa.ts` + - `apps/web/src/pwa.test.ts` +- Runtime touchpoints: + - `t3.claude.do` + - Home Screen installs on iPhone/iPad + - app routes under `/` including `/$threadId` +- If this breaks, look for: + - tapping a thread from the installed iPhone app opens Safari or an external web view + - deep links to chat/session routes stop feeling like in-app navigation + - install behavior changes after manifest or service-worker edits +- Verify with: + - `bun fmt` + - `bun lint` + - `bun typecheck` + - install the app from Safari on iPhone and open multiple thread routes + - confirm `manifest.webmanifest` reports `scope: "/"` and `start_url: "/"` +- Rollback notes: + - revert the files listed above + - redeploy production + - remove and re-add the Home Screen install on iPhone so Safari drops cached install metadata +- Notes: + - 2026-03-16: Added root-scoped manifest metadata, iOS standalone meta tags, and a minimal service worker registration path. + +## Backfill Needed + +Older fork-specific changes that predate this ledger should be added here over time as we touch them. Until then, use `git log upstream/main..main` as the catch-all diff against upstream.