From 78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 22 Jul 2026 14:33:07 -0700 Subject: [PATCH 1/5] feat(web): copy branch name via right-click in the branch selector (#4275) Co-authored-by: Claude Fable 5 --- .../BranchToolbarBranchSelector.tsx | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 67ae3a8187d..cfd09272aae 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,7 +3,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { @@ -17,9 +17,12 @@ import { useRef, useState, useTransition, + type MouseEvent as ReactMouseEvent, } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; @@ -317,6 +320,45 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Branch actions // --------------------------------------------------------------------------- + const copyBranchName = useCallback((branchName: string) => { + void writeTextToClipboard(branchName, "branch name").then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: "Branch name copied", + description: branchName, + }); + }, + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy branch name", + description: toBranchActionErrorMessage(error), + }), + ); + }, + ); + }, []); + + const handleBranchContextMenu = useCallback( + (event: ReactMouseEvent, branchName: string | null) => { + if (!branchName) return; + const api = readLocalApi(); + if (!api) return; + event.preventDefault(); + event.stopPropagation(); + const items: ContextMenuItem<"copy-branch-name">[] = [ + { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + ]; + void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => { + if (action === "copy-branch-name") copyBranchName(branchName); + }); + }, + [copyBranchName], + ); + const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action(); @@ -624,6 +666,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} className="pe-1.5" onClick={() => selectBranch(refName)} + onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
{itemValue} @@ -674,15 +717,23 @@ export function BranchToolbarBranchSelector({ {branchPrTooltip} ) : null} - } - className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" - disabled={isInitialBranchesLoadPending || isBranchActionPending} + {/* Context menu lives on the wrapper: the disabled Button has + pointer-events-none, so the trigger itself never sees right-clicks + while refs are loading or a branch action is pending. */} + handleBranchContextMenu(event, resolvedActiveBranch)} > - - {triggerLabel} - - + } + className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + disabled={isInitialBranchesLoadPending || isBranchActionPending} + > + + {triggerLabel} + + +
From ab4a88386d5227706fadc4feb045dfb3e7fe6e1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 23:49:21 +0200 Subject: [PATCH 2/5] Add remote server updates and standalone service management (#4286) Co-authored-by: codex --- README.md | 2 + apps/server/src/bin.test.ts | 17 +- apps/server/src/bin.ts | 2 + apps/server/src/cli/connect.test.ts | 4 +- apps/server/src/cli/connect.ts | 80 +-- apps/server/src/cli/service.test.ts | 37 ++ apps/server/src/cli/service.ts | 199 ++++++ apps/server/src/cloud/bootService.test.ts | 37 +- apps/server/src/cloud/bootService.ts | 101 ++- apps/server/src/cloud/pinnedRuntime.test.ts | 129 ++++ apps/server/src/cloud/pinnedRuntime.ts | 176 ++++++ apps/server/src/cloud/selfUpdate.test.ts | 589 ++++++++++++++++++ apps/server/src/cloud/selfUpdate.ts | 428 +++++++++++++ .../src/environment/ServerEnvironment.ts | 5 + apps/server/src/server.ts | 7 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 14 + apps/server/src/ws.ts | 9 + apps/web/src/components/ChatView.tsx | 30 +- .../components/ServerUpdateAction.test.tsx | 198 ++++++ .../web/src/components/ServerUpdateAction.tsx | 201 ++++++ .../settings/ConnectionsSettings.tsx | 34 +- apps/web/src/versionSkew.test.ts | 32 + apps/web/src/versionSkew.ts | 32 +- docs/README.md | 12 +- docs/architecture/overview.md | 2 + docs/architecture/remote.md | 15 + docs/architecture/server-updates.md | 121 ++++ docs/cloud/t3-connect-clerk.md | 3 + docs/operations/release.md | 21 + docs/user/background-service.md | 42 ++ docs/user/remote-access.md | 13 + docs/user/server-updates.md | 56 ++ packages/client-runtime/src/state/server.ts | 6 + packages/contracts/src/environment.ts | 21 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.ts | 29 +- 36 files changed, 2566 insertions(+), 149 deletions(-) create mode 100644 apps/server/src/cli/service.test.ts create mode 100644 apps/server/src/cli/service.ts create mode 100644 apps/server/src/cloud/pinnedRuntime.test.ts create mode 100644 apps/server/src/cloud/pinnedRuntime.ts create mode 100644 apps/server/src/cloud/selfUpdate.test.ts create mode 100644 apps/server/src/cloud/selfUpdate.ts create mode 100644 apps/web/src/components/ServerUpdateAction.test.tsx create mode 100644 apps/web/src/components/ServerUpdateAction.tsx create mode 100644 docs/architecture/server-updates.md create mode 100644 docs/user/background-service.md create mode 100644 docs/user/server-updates.md diff --git a/README.md b/README.md index 6aebfc7e8b8..0fbbe90ee66 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d ## Documentation - [Getting started](./docs/getting-started/quick-start.md) +- [Remote access](./docs/user/remote-access.md) +- [Keeping T3 Code in sync](./docs/user/server-updates.md) - [Architecture overview](./docs/architecture/overview.md) - [Provider guides](./docs/providers/codex.md) - [Operations](./docs/operations/ci.md) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 999547b71d8..91006a9bece 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -202,6 +202,18 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), ); + it.effect("exposes service lifecycle commands without T3 Connect configuration", () => + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["service", "--help"], noConnectCli)); + + assert.include(output, "Manage the T3 Code background service."); + assert.include(output, "install"); + assert.include(output, "uninstall"); + assert.include(output, "update"); + assert.include(output, "status"); + }), + ); + it.effect("reports fresh headless connect state without requiring local configuration", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( @@ -305,7 +317,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { runConnectCli(["connect", "logout", "--base-dir", baseDir]), ); - assert.equal(output, "Signed out of T3 Connect locally."); + assert.equal( + output, + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); assert.isFalse(NodeFS.existsSync(tokenPath)); }), ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index ddfbf5e3ecc..a7767b50a15 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -13,6 +13,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; +import { serviceCommand } from "./cli/service.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -47,6 +48,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, authCommand, projectCommand, + serviceCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index e63cbf331b5..cc52a50fe36 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -16,9 +16,9 @@ import { formatRelayClientReady, headlessSessionConfig, isPublishAgentActivityEnabledValue, - recoverBootServiceOffer, reportCloudDisconnectResults, } from "./connect.ts"; +import { recoverServiceOnboardingOffer } from "./service.ts"; it("explains how to complete headless authorization", () => { assert.equal( @@ -58,7 +58,7 @@ it.effect("detects headless operation from individual SSH config values", () => it.effect("treats cancelling optional background setup as a successful skip", () => Effect.gen(function* () { - const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({}))); + const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); assert.isFalse(result); }), ); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 8b11fc73346..0369d47e4d7 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,7 +6,6 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; -import * as Terminal from "effect/Terminal"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; @@ -20,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; +import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -29,7 +29,6 @@ import { } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; -import packageJson from "../../package.json" with { type: "json" }; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BootService from "../cloud/bootService.ts"; @@ -45,9 +44,13 @@ import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; -import * as ProcessRunner from "../processRunner.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { + bootServiceLayer, + offerServiceDuringOnboarding, + recoverServiceOnboardingOffer, +} from "./service.ts"; const jsonFlag = Flag.boolean("json").pipe( Flag.withDescription("Emit JSON instead of human-readable output."), @@ -396,21 +399,6 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { if (options.clearAuthorization) { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; - - // uninstall itself no-ops when nothing is installed (and on non-Linux), - // so no status pre-check that could mask a real removal failure. - const bootService = yield* BootService.BootService; - yield* bootService.uninstall.pipe( - Effect.tap((removed) => - removed ? Console.log("Removed the T3 Code background service.") : Effect.void, - ), - Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), - Effect.catch((error) => - Console.warn(`Could not remove the background service: ${error.message}`).pipe( - Effect.as(false), - ), - ), - ); } yield* reportCloudDisconnectResults({ @@ -420,7 +408,9 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { }); if (options.clearAuthorization) { - yield* Console.log("Signed out of T3 Connect locally."); + yield* Console.log( + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); } }); @@ -457,11 +447,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ( - offer: Effect.Effect, -) => - offer.pipe( - Effect.catchTags({ - QuitError: () => Effect.succeed(false), - BootServiceUnsupportedError: (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), - BootServiceCommandError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServiceInstallError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - }), - ); - export const connectCommand = Command.make("connect", { ...projectLocationFlags, headless: headlessFlag, @@ -748,7 +690,7 @@ export const connectCommand = Command.make("connect", { // Connect itself already succeeded; a boot-service failure must not // fail the command, just tell the user what happened and move on. - const background = yield* recoverBootServiceOffer(offerBootService); + const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); yield* Console.log( background ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out." diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts new file mode 100644 index 00000000000..e91e10000b6 --- /dev/null +++ b/apps/server/src/cli/service.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; + +import { formatServiceStatus } from "./service.ts"; + +const status = { + supported: true, + installed: true, + current: true, + unitPath: "/home/me/.config/systemd/user/t3code.service", + logPath: "/home/me/.t3/userdata/logs/boot-service.log", +} as const; + +it("reports the installed service version and host paths", () => { + assert.equal( + formatServiceStatus(status, "0.0.29"), + [ + "T3 Code service", + " Status: installed · t3@0.0.29", + " Unit: /home/me/.config/systemd/user/t3code.service", + " Logs: /home/me/.t3/userdata/logs/boot-service.log", + ].join("\n"), + ); +}); + +it("gives a direct repair command for a stale service", () => { + assert.include( + formatServiceStatus({ ...status, current: false }, "0.0.29"), + "Next: Run `npx t3@latest service update`.", + ); +}); + +it("explains service availability without systemd", () => { + assert.include( + formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), + "Supported on: Linux with systemd", + ); +}); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts new file mode 100644 index 00000000000..bd846eeee34 --- /dev/null +++ b/apps/server/src/cli/service.ts @@ -0,0 +1,199 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Terminal from "effect/Terminal"; +import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import type * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) => + BootService.layer({ + baseDir: config.baseDir, + logsDir: config.logsDir, + cliVersion: packageJson.version, + }).pipe(Layer.provide(ProcessRunner.layer)); + +export type ServiceReconcileResult = + | { + readonly changed: false; + readonly status: BootService.BootServiceStatus; + } + | { + readonly changed: true; + readonly previouslyInstalled: boolean; + readonly plan: BootService.BootServicePlan; + }; + +/** Install, update, or repair the service using the CLI version running this command. */ +export const reconcileService = Effect.fn("cli.service.reconcile")(function* () { + const service = yield* BootService.BootService; + const status = yield* service.status; + if (status.installed && status.current) { + return { changed: false, status } satisfies ServiceReconcileResult; + } + const plan = yield* service.install; + return { + changed: true, + previouslyInstalled: status.installed, + plan, + } satisfies ServiceReconcileResult; +}); + +export function formatServiceStatus( + status: BootService.BootServiceStatus, + cliVersion: string, +): string { + if (!status.supported) { + return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd"; + } + if (!status.installed) { + return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; + } + return [ + "T3 Code service", + ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`, + ` Unit: ${status.unitPath}`, + ` Logs: ${status.logPath}`, + ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), + ].join("\n"); +} + +const runServiceCommand = Effect.fn("cli.service.run")(function* ( + flags: { readonly baseDir: Parameters[0]["baseDir"] }, + run: Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* run.pipe(Effect.provide(bootServiceLayer(config))); +}); + +const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe( + Command.withDescription("Install T3 Code as a background service for this user."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log( + `T3 Code service is already installed with t3@${packageJson.version}.`, + ); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( + Command.withDescription( + "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", + ), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( + Command.withDescription("Stop and remove the T3 Code background service."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + const removed = yield* service.uninstall; + yield* Console.log( + removed ? "Removed the T3 Code service." : "T3 Code service is not installed.", + ); + }), + ), + ), +); + +const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( + Command.withDescription("Show whether the T3 Code background service is installed."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + }), + ), + ), +); + +export const offerServiceDuringOnboarding = Effect.gen(function* () { + const service = yield* BootService.BootService; + const { supported, installed, current } = yield* service.status; + if (!supported) { + return false; + } + if (installed && current) { + yield* Console.log("T3 Code is already set up to run in the background on this machine."); + return true; + } + const wanted = yield* Prompt.run( + Prompt.confirm({ + message: installed + ? "The installed T3 Code service needs an update or repair. Update it now?" + : "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", + initial: true, + }), + ); + if (!wanted) { + return false; + } + const result = yield* reconcileService(); + if (result.changed) { + yield* Console.log( + `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, + ); + } + return true; +}); + +export const recoverServiceOnboardingOffer = ( + offer: Effect.Effect, +) => + offer.pipe( + Effect.catchTags({ + QuitError: () => Effect.succeed(false), + BootServiceUnsupportedError: (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + BootServiceCommandError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceInstallError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + }), + ); + +export const serviceCommand = Command.make("service").pipe( + Command.withDescription("Manage the T3 Code background service."), + Command.withSubcommands([ + serviceInstallCommand, + serviceUninstallCommand, + serviceUpdateCommand, + serviceStatusCommand, + ]), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a24d54697d9..46e9a1da987 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -14,6 +14,7 @@ import { HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { reconcileService } from "../cli/service.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; @@ -100,7 +101,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { unit, [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", "StartLimitIntervalSec=300", "StartLimitBurst=5", "", @@ -108,6 +109,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { "Type=simple", "WorkingDirectory=%h", "Environment=T3CODE_HOME=/home/theo/.t3", + "Environment=T3_BOOT_SERVICE_UNIT=t3code.service", "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", "Restart=always", "RestartSec=5", @@ -170,6 +172,33 @@ it("flags package-manager cache entry points as ephemeral", () => { }); it.layer(NodeServices.layer)("BootService", (it) => { + it.effect("reconciles the standalone service once and is then idempotent", () => + Effect.gen(function* () { + const { dirs } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const first = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isTrue(first.changed); + if (!first.changed) return; + assert.isFalse(first.previouslyInstalled); + + const commandCount = commands.length; + const second = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isFalse(second.changed); + assert.lengthOf(commands, commandCount); + }), + ); + it.effect("installs the unit, enables the service, and enables linger", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); @@ -311,7 +340,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { }), ); - it.effect("reports an installed-but-stale unit so connect can offer a repair", () => + it.effect("reports an installed-but-stale unit so the lifecycle can offer a repair", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); const commands: Array = []; @@ -402,8 +431,8 @@ it.layer(NodeServices.layer)("BootService", (it) => { const error = yield* service.install.pipe(Effect.flip); assert.isTrue(isCommandError(error)); - // A leftover unit would make the next connect report "already set up" - // even though linger never happened. + // A leftover unit would make status report "installed" even though + // linger never happened. assert.isFalse( yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0662b367049..d7e13e834f4 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,21 +16,19 @@ import { } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; +import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; /** - * Installs T3 Code as a per-user boot service so a connected machine stays - * reachable through T3 Connect after the SSH session ends. Linux-only for - * now: systemd user unit + loginctl enable-linger. The service runs a pinned - * runtime installed under /runtime — never `npx t3`, whose cache is - * ephemeral and whose registry fetch at boot would make startup depend on - * the network. + * Installs T3 Code as a per-user boot service. Linux-only for now: systemd + * user unit + loginctl enable-linger. The service runs a stable or pinned + * runtime — never an ephemeral `npx t3` cache whose eviction could break + * startup. */ const BOOT_SERVICE_NAME = "t3code"; -const BOOT_RUNTIME_DIR = "runtime"; -const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; -const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; const EPHEMERAL_CACHE_SEGMENTS = [ "/_npx/", // npx @@ -92,7 +90,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { // relay connection, and Restart=always covers early-boot failures. return [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", // Give up after 5 crashes in 5 minutes so a persistently broken install // (deleted runtime, broken workspace) stops instead of restarting every // 5s forever and growing the unrotated append log without bound. @@ -103,6 +101,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, "Restart=always", "RestartSec=5", @@ -206,14 +205,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); - const runtimeVersionDir = path.join( - input.baseDir, - BOOT_RUNTIME_DIR, - "versions", - input.cliVersion, - ); - const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); - const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); const requireSystemdLinux = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { @@ -263,52 +255,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!isEphemeralCacheEntry(host.cliEntryPath)) { return; } - // The sentinel is written only after npm exits 0. Checking the entry - // file alone is not enough: npm extracts files before running native - // builds (node-pty), so a killed install leaves a plausible-looking but - // broken tree behind. - const alreadyPinned = yield* Effect.all([ - fs.exists(runtimeSentinelPath), - fs.exists(runtimeEntryPath), - ]).pipe( - Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - if (alreadyPinned) { - return; - } - yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( - Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - yield* runStep( - "installing the pinned t3 runtime (this can take a few minutes)", - "npm", - [ - "install", - "--prefix", - runtimeVersionDir, - "--no-fund", - "--no-audit", - `t3@${input.cliVersion}`, - ], - // Native deps (node-pty) can compile from source on slow boxes; the - // ProcessRunner default of 60s would kill a healthy install. - { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, - ).pipe( - Effect.tapError(() => - fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + yield* ensurePinnedRuntimeInstalled({ + baseDir: input.baseDir, + version: input.cliVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => + error.step.startsWith("installing") + ? new BootServiceCommandError({ + step: error.step, + exitCode: error.exitCode, + stdoutLength: error.stdoutLength, + stderrLength: error.stderrLength, + cause: error.cause, + }) + : new BootServiceInstallError({ cause: error }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), ), ); - yield* fs - .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); }); // Where the unit will point: derivable without touching the network, so // status can compare units purely; install materializes it first. const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) - ? runtimeEntryPath + ? runtimePaths.entryPath : host.cliEntryPath; const plan: BootServicePlan = { nodePath: host.execPath, @@ -341,8 +322,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); // If any activation step fails, remove the unit again: a leftover file - // would make the next `t3 connect` report the service as already set up - // even though it was never enabled or lingered. + // would make service status report it as installed even though it was + // never enabled or lingered. yield* Effect.gen(function* () { yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); yield* runStep("enabling the service", "systemctl", [ @@ -372,7 +353,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // fails), leave nothing behind: disable removes the enable symlink, remove // deletes the file, daemon-reload clears the stale definition — otherwise a // dangling wants/ symlink logs "Failed to load unit" at every boot and the - // next connect misreports the state. + // next lifecycle command misreports the state. const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( previousUnit: Option.Option, ) { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts new file mode 100644 index 00000000000..9b46c0038ec --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { + ensurePinnedRuntimeInstalled, + pinnedRuntimePaths, + removePinnedRuntimeInstallation, +} from "./pinnedRuntime.ts"; + +it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("serializes concurrent installs of the same runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.29"); + let npmRuns = 0; + + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + npmRuns += 1; + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + const install = ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.29", + fs, + path, + runner, + }); + + const first = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Deferred.await(installStarted); + const second = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Effect.yieldNow; + assert.equal(npmRuns, 1); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + assert.equal(npmRuns, 1); + assert.isTrue(yield* fs.exists(paths.sentinelPath)); + assert.isTrue(yield* fs.exists(paths.entryPath)); + }), + ); + + it.effect("waits for an active install before removing its runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.30"); + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + + const installFiber = yield* Effect.forkChild( + ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.30", + fs, + path, + runner, + }), + { startImmediately: true }, + ); + yield* Deferred.await(installStarted); + const removeFiber = yield* Effect.forkChild( + removePinnedRuntimeInstallation({ + baseDir, + version: "0.0.30", + fs, + path, + }), + { startImmediately: true }, + ); + yield* Effect.yieldNow; + assert.isTrue(yield* fs.exists(paths.versionDir)); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(installFiber); + yield* Fiber.join(removeFiber); + assert.isFalse(yield* fs.exists(paths.versionDir)); + }), + ); +}); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts new file mode 100644 index 00000000000..e3e095ce081 --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -0,0 +1,176 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * A pinned runtime is an exact `t3@` npm-installed into + * /runtime/versions/. The boot service points its systemd + * unit here, and server self-update installs the target version here before + * switching over — never `npx t3`, whose cache is ephemeral and whose + * registry fetch at boot would make startup depend on the network. + */ + +const PINNED_RUNTIME_DIR = "runtime"; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +// Boot-service setup and remote self-update share this module but can be +// constructed in separate layers. Serialize the complete check/install/ +// sentinel transaction across all callers in this process. +const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); + +export interface PinnedRuntimePaths { + readonly versionDir: string; + readonly entryPath: string; + readonly sentinelPath: string; +} + +export function pinnedRuntimePaths( + path: Path.Path, + baseDir: string, + version: string, +): PinnedRuntimePaths { + const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + return { + versionDir, + entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: path.join(versionDir, ".install-complete"), + }; +} + +export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass()( + "PinnedRuntimeInstallError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Pinned runtime install failed while ${this.step}.` + : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`; + } +} + +/** + * Installs `t3@` into the pinned runtime directory unless a complete + * install is already there, and returns its paths. The sentinel is written + * only after npm exits 0; checking the entry file alone is not enough — npm + * extracts files before running native builds (node-pty), so a killed + * install leaves a plausible-looking but broken tree behind. + */ +export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly runner: ProcessRunner.ProcessRunner["Service"]; + }) { + const { fs, runner } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + + return yield* pinnedRuntimeInstallLock.withPermit( + Effect.gen(function* () { + const alreadyPinned = yield* Effect.all([ + fs.exists(paths.sentinelPath), + fs.exists(paths.entryPath), + ]).pipe( + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), + ), + ); + if (alreadyPinned) { + return paths; + } + + yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "preparing the pinned runtime directory", + cause, + }), + ), + ); + + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + yield* runner + .run({ + command: "npm", + args: [ + "install", + "--prefix", + paths.versionDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError(() => + fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + + yield* fs + .writeFileString(paths.sentinelPath, `${input.version}\n`) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), + ), + ); + + return paths; + }), + ); + }, +); + +/** Removes one pinned runtime while holding the same process-wide lock used + * by install/check/sentinel work, so cleanup cannot race another caller that + * is materializing or reusing the runtime tree. */ +export const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + }) { + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + yield* pinnedRuntimeInstallLock.withPermit( + input.fs + .remove(paths.versionDir, { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "removing the pinned runtime", cause }), + ), + ), + ); + }, +); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts new file mode 100644 index 00000000000..28bcc0ffe62 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -0,0 +1,589 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + renderBootServiceUnit, +} from "./bootService.ts"; +import * as SelfUpdate from "./selfUpdate.ts"; + +const NODE_PATH = "/usr/local/bin/node"; + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { + readonly failWhen?: ((command: string, args: ReadonlyArray) => boolean) | undefined; + readonly stdoutFor?: + | ((command: string, args: ReadonlyArray) => string | undefined) + | undefined; + }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + commands.push({ command: input.command, args: input.args }); + const failed = options?.failWhen?.(input.command, input.args) === true; + const versionFromPath = + input.command === NODE_PATH && input.args[1] === "--version" + ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1] + : undefined; + return { + stdout: + options?.stdoutFor?.(input.command, input.args) ?? + (versionFromPath === undefined ? "" : `${versionFromPath}\n`), + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const provideHostRefs = (input: { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly entryPath: string; +}) => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed(HostProcessEnvironment, input.env), + Layer.succeed(HostProcessExecutablePath, NODE_PATH), + Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), + ), + ); + +it("recognizes published npm artifacts as swappable entry points", () => { + assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isTrue( + SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + SelfUpdate.isPublishedCliEntry( + "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + ), + ); + // Dev checkouts and the desktop bundle run apps/server/dist directly. + assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); + assert.isFalse(SelfUpdate.isPublishedCliEntry("")); +}); + +it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { + const makeHome = Effect.fn("test.makeHome")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + return { fs, path, home }; + }); + + const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( + home: string, + entryPath: string, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir: path.join(home, ".t3"), + logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + }); + + it.effect("reports boot-service for the systemd-spawned unit process", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "boot-service"); + }), + ); + + it.effect("does not claim a systemd process owned by another unit", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { HOME: home, INVOCATION_ID: "abc123" }, + entryPath, + }), + ); + assert.isNull(method); + }), + ); + + it.effect("reports respawn for a manual run of the pinned artifact", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + // Same unit on disk, but no INVOCATION_ID: restarting the unit would + // not replace this process, so it must respawn itself instead. + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports respawn for a foreground npx artifact on darwin", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, + }), + ); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports desktop-managed for desktop-supervised backends", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + // Desktop ownership wins over every process-shape heuristic: even a + // systemd-looking pinned artifact belongs to the app that spawned it. + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: true, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "desktop-managed"); + }), + ); + + it.effect("reports no method for dev checkouts and Windows", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, + }), + ); + assert.isNull(devMethod); + const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "win32", + env: { HOME: home }, + entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + }), + ); + assert.isNull(windowsMethod); + }), + ); +}); + +it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { + interface RecordedSpawn { + readonly command: string; + readonly args: ReadonlyArray; + } + + const makeContext = Effect.fn("test.makeContext")(function* (options?: { + readonly platform?: NodeJS.Platform; + readonly bootService?: boolean; + readonly desktopManaged?: boolean; + readonly entryPath?: string; + readonly failWhen?: (command: string, args: ReadonlyArray) => boolean; + readonly stdoutFor?: (command: string, args: ReadonlyArray) => string | undefined; + readonly failSpawn?: boolean; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + const baseDir = path.join(home, ".t3"); + const entryPath = + options?.entryPath ?? + path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + const env: NodeJS.ProcessEnv = + options?.bootService === true + ? { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + } + : { HOME: home }; + if (options?.bootService === true) { + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir, + logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + } + + const commands: Array = []; + const spawns: Array = []; + let exited = 0; + // layerTest always reports mode "web"; desktop-managed contexts overlay + // the mode the desktop app's bootstrap envelope would set. + const configLayer = + options?.desktopManaged === true + ? Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { ...config, mode: "desktop" as const }; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) + : ServerConfig.layerTest(home, baseDir); + const service = yield* SelfUpdate.make({ + host: { + spawnDetached: (command, args) => + Effect.sync(() => spawns.push({ command, args })).pipe( + Effect.andThen( + options?.failSpawn === true + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause: new Error("detached spawn failed"), + }), + ) + : Effect.void, + ), + ), + exitProcess: () => { + exited += 1; + }, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeRecordingRunnerLayer(commands, { + failWhen: options?.failWhen, + stdoutFor: options?.stdoutFor, + }), + configLayer, + ), + ), + provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), + ); + return { + fs, + path, + home, + baseDir, + entryPath, + commands, + spawns, + exitCount: () => exited, + service, + }; + }); + + it.effect("rejects dist-tags and other non-exact versions", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); + assert.include(error.reason, "not an exact t3 version"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("refuses to update a desktop-managed backend and points at the app", () => + Effect.gen(function* () { + const context = yield* makeContext({ desktopManaged: true, bootService: true }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "desktop app"); + assert.lengthOf(context.commands, 0); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("fails without touching anything when no update method applies", () => + Effect.gen(function* () { + const context = yield* makeContext({ + entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", + }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "cannot update itself"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("surfaces a failed npm install and never schedules a restart", () => + Effect.gen(function* () { + const context = yield* makeContext({ failWhen: (command) => command === "npm" }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.equal(error.reason, "Could not install the requested t3 version."); + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("reinstalls the same version after a failed preflight", () => + Effect.gen(function* () { + let preflightAttempts = 0; + const context = yield* makeContext({ + failWhen: (command) => { + if (command !== NODE_PATH) return false; + preflightAttempts += 1; + return preflightAttempts === 1; + }, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true }); + yield* context.fs.writeFileString(entryPath, "export {};\n"); + yield* context.fs.writeFileString( + context.path.join(versionDir, ".install-complete"), + "0.0.29\n", + ); + + const firstError = yield* context.service + .update({ targetVersion: "0.0.29" }) + .pipe(Effect.flip); + assert.include(firstError.reason, "failed its version check"); + assert.isFalse(yield* context.fs.exists(versionDir)); + + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.deepEqual( + context.commands.map((entry) => entry.command), + [NODE_PATH, "npm", NODE_PATH], + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rejects and removes an installed runtime that reports the wrong version", () => + Effect.gen(function* () { + const context = yield* makeContext({ + stdoutFor: (command, args) => + command === NODE_PATH && args[1] === "--version" ? "0.0.28\n" : undefined, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + + assert.include(error.reason, "did not report the requested"); + assert.isFalse(yield* context.fs.exists(versionDir)); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("reports a detached replacement spawn failure and leaves updates retryable", () => + Effect.gen(function* () { + const context = yield* makeContext({ failSpawn: true }); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Could not start the replacement"); + + const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(second.reason, "Could not start the replacement"); + assert.notInclude(second.reason, "already in progress"); + assert.lengthOf(context.spawns, 2); + assert.equal(context.exitCount(), 0); + }), + ); + + it.effect("installs, preflights, and respawns a foreground server", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.lengthOf(context.spawns, 1); + + const concurrentError = yield* context.service + .update({ targetVersion: "0.0.30" }) + .pipe(Effect.flip); + assert.include(concurrentError.reason, "already in progress"); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + assert.deepEqual( + context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, + `${NODE_PATH} ${pinnedEntry} --version`, + ], + ); + + // The restart is deferred so the RPC acknowledgement flushes first. + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 1); + const spawn = context.spawns[0]; + assert.equal(spawn?.command, "/bin/sh"); + assert.include(spawn?.args ?? [], pinnedEntry); + // The replacement replays the original CLI arguments. + assert.include(spawn?.args ?? [], "serve"); + assert.equal(context.exitCount(), 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rewrites the systemd unit and restarts the boot service", () => + Effect.gen(function* () { + const context = yield* makeContext({ bootService: true }); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + const unit = yield* context.fs.readFileString( + context.path.join(context.home, ".config", "systemd", "user", "t3code.service"), + ); + assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); + + assert.deepEqual(context.commands[3], { + command: "systemctl", + args: ["--user", "restart", "t3code.service"], + }); + assert.lengthOf(context.spawns, 0); + // systemd replaces the process; the server must not exit itself. + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous unit and permits a retry when systemd restart fails", () => + Effect.gen(function* () { + let failRestart = true; + const context = yield* makeContext({ + bootService: true, + failWhen: (command, args) => { + if (command !== "systemctl" || args[1] !== "restart" || !failRestart) { + return false; + } + failRestart = false; + return true; + }, + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Restarting the systemd boot service failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.slice(-2).map((entry) => entry.args), + [ + ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + ["--user", "daemon-reload"], + ], + ); + + const retry = yield* context.service.update({ targetVersion: "0.0.30" }); + assert.deepEqual(retry, { targetVersion: "0.0.30", method: "boot-service" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous systemd unit when daemon-reload fails", () => + Effect.gen(function* () { + const context = yield* makeContext({ + bootService: true, + failWhen: (command) => command === "systemctl", + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "Reloading systemd units failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts new file mode 100644 index 00000000000..2df49910bd5 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.ts @@ -0,0 +1,428 @@ +// @effect-diagnostics nodeBuiltinImport:off +// node:child_process directly: the foreground-server replacement must be a +// detached fire-and-forget child that outlives this process, while Effect's +// ChildProcessSpawner ties every child to a scope that kills it. +import { + ServerSelfUpdateError, + type ServerSelfUpdateCapability, + type ServerSelfUpdateInput, + type ServerSelfUpdateResult, +} from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ServerConfig from "../config.ts"; +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + quoteSystemdValue, + renderBootServiceUnit, +} from "./bootService.ts"; +import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts"; + +/** + * Lets a connected client replace this server with another published `t3` + * version over RPC — the only update path that works when the user is not at + * the machine (phone against a home server, relay-managed box). The target + * version is npm-installed into the pinned runtime and verified before + * anything restarts, so a failed install leaves the running server untouched. + */ + +const PREFLIGHT_TIMEOUT = Duration.seconds(30); +/** Grace between acknowledging the RPC and killing the process, so the + response (and its relay hop) flushes before the socket drops. */ +const RESTART_DELAY = Duration.seconds(2); + +/** Exact npm versions only — never dist-tags — so the acknowledgement names + the version that was actually installed. Also keeps the value safe to + pass to npm and embed in filesystem paths. */ +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export interface ServerSelfUpdateHost { + readonly execPath: string; + readonly cliEntryPath: string; + /** Original CLI arguments after the entry path, replayed on respawn. */ + readonly cliArgs: ReadonlyArray; + /** Resolves once the foreground replacement process has actually spawned. */ + readonly spawnDetached: ( + command: string, + args: ReadonlyArray, + ) => Effect.Effect; + readonly exitProcess: () => void; +} + +function normalizeEntryPath(entryPath: string): string { + return entryPath.replaceAll("\\", "/"); +} + +/** + * Only a published npm artifact can be swapped for another version: dev + * checkouts (apps/server/dist) and the desktop app's bundled backend have no + * npm identity, and the desktop manages its own updates. + */ +export function isPublishedCliEntry(entryPath: string): boolean { + return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/"); +} + +/** + * The update path this process can offer, or null when only a manual + * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this + * backend and owns its version; only updating the app updates it. + * "boot-service" — this is the systemd-supervised process from + * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a + * foreground POSIX process running a published artifact: replace it with a + * detached child. Windows foreground runs are unsupported for now (no + * equivalent of the detach-and-exec handoff below). + */ +export const resolveServerSelfUpdateCapability = Effect.fn( + "cloud.server_self_update.resolve_capability", +)(function* (input: { + /** True when the desktop app supervises this backend (mode "desktop"). */ + readonly desktopManaged: boolean; +}) { + if (input.desktopManaged) { + return "desktop-managed" as const; + } + + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; + const hostArguments = yield* HostProcessArguments; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entryPath = hostArguments[1] ?? ""; + if (entryPath === "") { + return null; + } + + const homeDir = env.HOME ?? ""; + if (platform === "linux" && homeDir !== "") { + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe( + Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))), + Effect.orElseSucceed(() => false), + ); + // INVOCATION_ID only proves that some systemd unit launched us. The + // explicit marker written into t3code.service identifies this unit as the + // supervisor that will replace the current process when restarted. + if ( + unitReferencesEntry && + (env.INVOCATION_ID ?? "") !== "" && + env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE + ) { + return "boot-service" as const; + } + + // A process owned by another (or a legacy unmarked) systemd unit must not + // use the foreground respawn path: Restart=always could otherwise launch + // the old unit beside the detached replacement. + if ((env.INVOCATION_ID ?? "") !== "") { + return null; + } + } + + if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) { + return "respawn" as const; + } + + return null; +}); + +export class ServerSelfUpdate extends Context.Service< + ServerSelfUpdate, + { + readonly update: ( + input: ServerSelfUpdateInput, + ) => Effect.Effect; + } +>()("t3/cloud/selfUpdate/ServerSelfUpdate") {} + +export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: { + readonly host?: Partial; +}) { + const serverConfig = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const env = yield* HostProcessEnvironment; + const hostExecPath = yield* HostProcessExecutablePath; + const hostArguments = yield* HostProcessArguments; + const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); + + const host: ServerSelfUpdateHost = { + execPath: options?.host?.execPath ?? hostExecPath, + cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "", + cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2), + spawnDetached: + options?.host?.spawnDetached ?? + ((command, args) => + Effect.callback((resume) => { + const spawnError = (cause: unknown) => + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause, + }); + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn(command, [...args], { + detached: true, + stdio: "ignore", + }); + } catch (cause) { + resume(Effect.fail(spawnError(cause))); + return; + } + + const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause))); + child.once("error", onSpawnError); + child.once("spawn", () => { + child.removeListener("error", onSpawnError); + // Keep asynchronous child errors from becoming uncaught after the + // successful spawn handoff has already been acknowledged. + child.on("error", () => undefined); + child.unref(); + resume(Effect.void); + }); + })), + exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)), + }; + + const inFlight = yield* Ref.make(false); + + const failWith = (reason: string, cause?: unknown) => + cause === undefined + ? new ServerSelfUpdateError({ reason }) + : new ServerSelfUpdateError({ reason, cause }); + + /** Deferred so the RPC acknowledgement flushes before the process dies. + Detached from the request scope: the triggering connection is exactly + what the restart tears down. */ + const scheduleRestart = (restart: Effect.Effect) => + Effect.sleep(RESTART_DELAY).pipe( + Effect.andThen(restart), + Effect.forkDetach({ startImmediately: true }), + ); + const writeUnitAtomically = (filePath: string, contents: string) => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( + "cloud.server_self_update.update", + )(function* (input) { + if (capability === "desktop-managed") { + return yield* failWith( + "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", + ); + } + if (capability === null) { + return yield* failWith( + "This server cannot update itself; relaunch it manually with the new version.", + ); + } + const activeMethod = capability; + const targetVersion = input.targetVersion.trim(); + if (!EXACT_VERSION_PATTERN.test(targetVersion)) { + return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); + } + + const alreadyRunning = yield* Ref.getAndSet(inFlight, true); + if (alreadyRunning) { + return yield* failWith("A server update is already in progress."); + } + + return yield* Effect.gen(function* () { + const runtimePaths = yield* ensurePinnedRuntimeInstalled({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), + ); + + // A broken artifact (failed native build, incompatible node) must be + // caught while the current server is still alive to report it. + const preflight = yield* runner + .run({ + command: host.execPath, + args: [runtimePaths.entryPath, "--version"], + timeout: PREFLIGHT_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => + failWith(`Could not verify the installed t3@${targetVersion}.`, cause), + ), + ); + const preflightVersion = preflight.stdout.trim(); + if (preflight.code !== 0 || preflightVersion !== targetVersion) { + // A completed npm install can still be unusable under this Node or on + // this machine. Remove its sentinel and tree so a retry of the same + // version performs a clean install instead of reusing a known-bad one. + yield* removePinnedRuntimeInstallation({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + }).pipe( + Effect.mapError((error) => + failWith(`Could not remove the failed t3@${targetVersion} installation.`, error), + ), + ); + return yield* failWith( + preflight.code !== 0 + ? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).` + : `The installed runtime did not report the requested t3@${targetVersion} version.`, + ); + } + + if (activeMethod === "boot-service") { + const homeDir = env.HOME ?? ""; + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const previousUnit = yield* fs + .readFileString(unitPath) + .pipe( + Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)), + ); + // Same shape bootService.install writes, so host lifecycle commands + // still recognize the unit as current. + const unit = renderBootServiceUnit({ + nodePath: host.execPath, + t3EntryPath: runtimePaths.entryPath, + baseDir: serverConfig.baseDir, + logPath: path.join(serverConfig.logsDir, "boot-service.log"), + unitPath, + }); + yield* writeUnitAtomically(unitPath, unit).pipe( + Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)), + ); + + const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () { + const reload = yield* runner + .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) + .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); + if (reload.code !== 0) { + return yield* failWith( + `Reloading systemd units failed (exit code ${String(reload.code)}).`, + ); + } + }); + + yield* reloadSystemd().pipe( + Effect.catch((reloadError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.mapError((rollbackCause) => + failWith("Could not restore the previous systemd unit.", { + reloadError, + rollbackCause, + }), + ), + // Systemd should still have the old unit in memory after the + // failed reload, but retry after restoring in case it applied a + // partial update before returning an error. + Effect.andThen(reloadSystemd().pipe(Effect.ignore)), + Effect.andThen(Effect.fail(reloadError)), + ), + ), + ); + yield* Effect.logInfo("Server self-update installed; restarting boot service.", { + targetVersion, + }); + // A successful systemd restart stops this process, so the RPC is + // interrupted and the reconnecting client observes the new version. + // A rejected restart returns while the old process is still alive; + // restore the previous unit and report that failure through the RPC. + yield* Effect.gen(function* () { + const restart = yield* runner + .run({ + command: "systemctl", + args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + }) + .pipe( + Effect.mapError((cause) => + failWith("Could not restart the systemd boot service.", cause), + ), + ); + if (restart.code !== 0) { + return yield* failWith( + `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`, + ); + } + }).pipe( + Effect.catch((restartError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.andThen(reloadSystemd()), + Effect.mapError((rollbackError) => + failWith("Could not restore the previous systemd unit.", { + restartError, + rollbackError, + }), + ), + Effect.andThen(Effect.fail(restartError)), + ), + ), + ); + } else { + // Spawn the shim before acknowledging the RPC so ENOENT/EACCES and + // other launch failures leave this server alive and return a useful + // error. The shim itself waits until after the acknowledgement and + // deferred exit before binding the replacement server. + yield* host + .spawnDetached("/bin/sh", [ + "-c", + 'sleep 3; exec "$@"', + "t3-self-update", + host.execPath, + runtimePaths.entryPath, + ...host.cliArgs, + ]) + .pipe( + Effect.mapError((cause) => + failWith("Could not start the replacement t3 process.", cause), + ), + ); + yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion }); + yield* scheduleRestart( + Effect.try({ + try: () => host.exitProcess(), + catch: (cause) => failWith("Could not exit the replaced t3 process.", cause), + }).pipe( + Effect.catch((error) => + Effect.logError("Server self-update could not exit the replaced process.").pipe( + Effect.annotateLogs({ targetVersion, error: error.reason }), + Effect.ensuring(Ref.set(inFlight, false)), + ), + ), + ), + ); + } + + return { targetVersion, method: activeMethod }; + }).pipe(Effect.onError(() => Ref.set(inFlight, false))); + }); + + return ServerSelfUpdate.of({ update }); +}); + +export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 01567b98d32..181237d76b6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; +import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; @@ -124,6 +125,9 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -137,6 +141,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0e6db87b109..21d0ebe5fff 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -79,6 +79,7 @@ import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -361,7 +362,11 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), -).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); +).pipe( + Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(ServerSelfUpdate.layer), + Layer.provide(browserApiCorsLayer), +); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ecd2d0ad2b0..7b5956de07f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -713,6 +713,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.equal(reusedForSshScheme, "origin"); + const reusedForBareSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://github.com/pingdotgg/t3code", + }); + assert.equal(reusedForBareSshScheme, "origin"); + + const reusedForSshPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code", + }); + assert.equal(reusedForSshPort, "origin"); + const reusedForSshWithPort = yield* driver.ensureRemote({ cwd, preferredName: "pingdotgg", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08b1770a0a2..f6f46d1e76e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,6 +78,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -296,6 +297,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], + [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], @@ -418,6 +420,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -1476,6 +1479,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverUpdateServer]: (input) => + observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, @@ -2079,6 +2086,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; return HttpRouter.add( "GET", "/ws", @@ -2101,6 +2109,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ad303f607e7..1e5e1ee2a5c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -254,11 +254,14 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, + serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -1784,6 +1787,9 @@ function ChatViewContent(props: ChatViewProps) { hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; + const versionMismatchEnvironmentId = + versionMismatch && activeThread ? activeThread.environmentId : null; + const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { @@ -1822,7 +1828,12 @@ function ChatViewContent(props: ChatViewProps) { ), }); } - if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { + if ( + showVersionMismatchBanner && + versionMismatch && + versionMismatchDismissKey && + versionMismatchEnvironmentId + ) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, variant: "warning", @@ -1831,9 +1842,21 @@ function ChatViewContent(props: ChatViewProps) { description: ( <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} - {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. + {versionMismatch.serverVersion}.{" "} + {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} ), + // The desktop-managed guidance is already the description; the action + // slot would only repeat it. + actions: + versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + + ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); @@ -1846,9 +1869,12 @@ function ChatViewContent(props: ChatViewProps) { activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, navigate, + setDismissedVersionMismatchKey, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, + versionMismatchEnvironmentId, + versionMismatchSelfUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx new file mode 100644 index 00000000000..c0ba1c693d4 --- /dev/null +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -0,0 +1,198 @@ +import type { Dispatch, ReactElement, SetStateAction } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { EnvironmentId } from "@t3tools/contracts"; + +const testState = vi.hoisted(() => ({ + updateServer: vi.fn(), + toast: vi.fn(), +})); + +const hooks = vi.hoisted(() => { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useEffect() { + nextIndex(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useRef(initialValue: T): { current: T } { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = { current: initialValue }; + } + return slots[index] as { current: T }; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +}); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useEffect: hooks.useEffect, + useRef: hooks.useRef, + useState: hooks.useState, + }; +}); + +vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); +vi.mock("~/hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), +})); +vi.mock("~/state/server", () => ({ + serverEnvironment: { updateServer: Symbol("updateServer") }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => testState.updateServer, +})); +vi.mock("./ui/toast", () => ({ + toastManager: { add: testState.toast }, +})); + +import { ServerUpdateAction } from "./ServerUpdateAction"; + +type ActionElement = ReactElement<{ + readonly disabled?: boolean; + readonly onClick?: () => void; +}>; + +function renderAction(): ActionElement { + hooks.beginRender(); + return ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: "boot-service", + targetVersion: "0.0.29", + }) as ActionElement; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("ServerUpdateAction", () => { + beforeEach(() => { + vi.useFakeTimers(); + hooks.reset(); + testState.updateServer.mockReset(); + testState.toast.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts a fresh reconnect timeout after a long install succeeds", async () => { + const update = + deferred< + ReturnType> + >(); + testState.updateServer.mockReturnValue(update.promise); + + renderAction().props.onClick?.(); + expect(renderAction().props.disabled).toBe(true); + + await vi.advanceTimersByTimeAsync(11 * 60_000); + update.resolve( + AsyncResult.success({ + targetVersion: "0.0.29", + method: "boot-service", + }), + ); + await flushPromises(); + + // The click-based deadline would have fired by now. Success gets a fresh + // twelve-minute reconnect window, so the action remains disabled. + await vi.advanceTimersByTimeAsync(2 * 60_000); + expect(renderAction().props.disabled).toBe(true); + expect(testState.toast).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Server update timed out" }), + ); + + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(renderAction().props.disabled).not.toBe(true); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Server update timed out" }), + ); + }); + + it("does not let an expired request clear a newer retry", async () => { + const first = deferred>(); + const retry = + deferred< + ReturnType> + >(); + testState.updateServer.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise); + + renderAction().props.onClick?.(); + await vi.advanceTimersByTimeAsync(12 * 60_000); + expect(renderAction().props.disabled).not.toBe(true); + + renderAction().props.onClick?.(); + expect(renderAction().props.disabled).toBe(true); + + first.resolve(AsyncResult.failure(Cause.fail(new Error("first request failed late")))); + await flushPromises(); + + expect(renderAction().props.disabled).toBe(true); + expect(testState.updateServer).toHaveBeenCalledTimes(2); + + retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" })); + await flushPromises(); + expect(renderAction().props.disabled).toBe(true); + }); + + it("quietly releases the action when a restart RPC is interrupted", async () => { + testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); + + renderAction().props.onClick?.(); + await flushPromises(); + + expect(renderAction().props.disabled).not.toBe(true); + expect(testState.toast).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Server update failed" }), + ); + }); +}); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx new file mode 100644 index 00000000000..5e82c4e4d93 --- /dev/null +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -0,0 +1,201 @@ +import { useEffect, useRef, useState } from "react"; +import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { manualServerUpdateCommand } from "~/versionSkew"; +import { Button } from "./ui/button"; +import { Spinner } from "./ui/spinner"; +import { toastManager } from "./ui/toast"; + +/** + * The npm install on the server side is capped at 10 minutes; expire the + * spinner a bit beyond that so a dead transport never strands a disabled + * button, while a legitimately slow install is never cut off. + */ +const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; + +function updateFailureMessage(error: unknown): string { + return error instanceof Error ? error.message : "Server update failed."; +} + +/** + * The call-to-action for a version-skewed server, matched to the update path + * it advertises: a one-click install-and-restart for servers that can update + * themselves, an update-the-desktop-app hint for desktop-managed backends + * (running `npx t3` there would start a second server, not update this one), + * and copying the manual relaunch command for everything else — so the skew + * warning always offers a way out. + */ +export function ServerUpdateAction({ + environmentId, + serverLabel, + selfUpdate, + targetVersion, +}: { + readonly environmentId: EnvironmentId; + readonly serverLabel: string; + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly targetVersion: string; +}) { + const updateServer = useAtomCommand(serverEnvironment.updateServer, { + reportFailure: false, + }); + const [pending, setPending] = useState(false); + const inFlightRef = useRef(false); + const attemptRef = useRef(0); + const expiryRef = useRef | null>(null); + const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ + target: "update command", + onCopy: ({ command }) => { + toastManager.add({ + type: "success", + title: "Update command copied", + description: `Run \`${command}\` on ${serverLabel} to update it.`, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Could not copy update command", + description: error.message, + }); + }, + }); + + useEffect( + () => () => { + if (expiryRef.current !== null) { + clearTimeout(expiryRef.current); + expiryRef.current = null; + } + attemptRef.current += 1; + inFlightRef.current = false; + }, + [], + ); + + const handleUpdate = () => { + // Synchronous re-entry guard: setPending is async, so a rapid + // double-click would otherwise dispatch two updates. + if (inFlightRef.current) { + return; + } + inFlightRef.current = true; + const attempt = attemptRef.current + 1; + attemptRef.current = attempt; + const ownsAttempt = () => attemptRef.current === attempt; + setPending(true); + const armExpiry = () => { + const expiry = setTimeout(() => { + if (!ownsAttempt()) return; + expiryRef.current = null; + attemptRef.current += 1; + inFlightRef.current = false; + setPending(false); + toastManager.add({ + type: "error", + title: "Server update timed out", + description: "The update may still be running on the server — check again in a minute.", + }); + }, UPDATE_PENDING_EXPIRY_MS); + expiryRef.current = expiry; + return expiry; + }; + let expiry = armExpiry(); + let restartAccepted = false; + const keepPendingForRestart = () => { + restartAccepted = true; + if (expiryRef.current === expiry) { + clearTimeout(expiry); + expiry = armExpiry(); + } + }; + void Promise.resolve() + .then(() => + updateServer({ + environmentId, + input: { targetVersion }, + }), + ) + .then((result) => { + if (!ownsAttempt()) return; + if (result._tag === "Failure") { + // An interrupt may be the expected boot-service disconnect, but it + // can also be client-side cancellation before restart was accepted. + // Release the action quietly; version sync will remove it when a + // successful replacement reconnects. + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(squashAtomCommandFailure(result)), + }); + return; + } + keepPendingForRestart(); + // Installation can legitimately consume most of the request window. + // Give restart/reconnect a fresh full window after the server accepts + // the handoff instead of expiring based on the original click time. + toastManager.add({ + type: "success", + title: `Updating ${serverLabel}`, + description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`, + }); + }) + .catch((error: unknown) => { + if (!ownsAttempt()) return; + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(error), + }); + }) + .finally(() => { + // A successful RPC only acknowledges that restart is scheduled. Keep + // the action disabled until version sync unmounts it, or until the + // safety expiry reports that reconnection never arrived. + if (restartAccepted || !ownsAttempt() || expiryRef.current !== expiry) return; + expiryRef.current = null; + clearTimeout(expiry); + attemptRef.current += 1; + inFlightRef.current = false; + setPending(false); + }); + }; + + if (selfUpdate === "desktop-managed") { + return ( + + Update the desktop app on that machine to update this server. + + ); + } + + if (selfUpdate === null) { + const command = manualServerUpdateCommand(targetVersion); + return ( + + ); + } + + return pending ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 385b244577e..734a4e373f0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -106,7 +106,10 @@ import { } from "~/environments/primary"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { useUiStateStore } from "~/uiStateStore"; -import { resolveServerConfigVersionMismatch } from "~/versionSkew"; +import { + resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, +} from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; @@ -129,6 +132,7 @@ import { } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ServerUpdateAction } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; @@ -1425,11 +1429,19 @@ function SavedBackendListRow({

{metadataBits.join(" · ")}

) : null} {versionMismatch ? ( -

- - Version drift: client {versionMismatch.clientVersion}, server{" "} - {versionMismatch.serverVersion}. -

+
+

+ + Version drift: client {versionMismatch.clientVersion}, server{" "} + {versionMismatch.serverVersion}. +

+ +
) : null} {environment.connection.error ? (

@@ -2982,6 +2994,16 @@ export function ConnectionsSettings() { fail. } + control={ + primaryEnvironmentId !== null ? ( + + ) : undefined + } /> ) : null} {desktopBridge ? ( diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index b1f2189c0a3..da4ae1e2241 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -8,7 +8,9 @@ import { dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, resolveVersionMismatch, + serverUpdateGuidance, } from "./versionSkew"; describe("versionSkew", () => { @@ -75,4 +77,34 @@ describe("versionSkew", () => { "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.", ); }); + + it("reads desktop-managed update capabilities from config descriptors", () => { + expect( + resolveServerSelfUpdateCapability({ + environment: { + environmentId: EnvironmentId.make("environment-desktop"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "9.9.9", + capabilities: { + repositoryIdentity: true, + serverSelfUpdate: "desktop-managed", + }, + }, + }), + ).toBe("desktop-managed"); + expect(resolveServerSelfUpdateCapability(null)).toBeNull(); + }); + + it("matches version-drift guidance to the advertised update path", () => { + expect(serverUpdateGuidance("respawn", "Remote server")).toBe( + "Update the Remote server so they stay in sync.", + ); + expect(serverUpdateGuidance("desktop-managed", "Desktop server")).toBe( + "The Desktop server is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.", + ); + expect(serverUpdateGuidance(null, "Local server")).toBe( + "Relaunch the Local server with the copied command to sync them.", + ); + }); }); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 88691cfc25e..6cf2a474269 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { APP_VERSION } from "./branding"; @@ -49,6 +49,36 @@ export function resolveServerConfigVersionMismatch( return resolveVersionMismatch(serverConfig?.environment.serverVersion); } +/** The update path the connected server offers, or null when it only + supports a manual relaunch (older servers, dev checkouts, Windows). */ +export function resolveServerSelfUpdateCapability( + serverConfig: Pick | null | undefined, +): ServerSelfUpdateCapability | null { + return serverConfig?.environment.capabilities.serverSelfUpdate ?? null; +} + +/** The command to hand users whose server cannot update itself. */ +export function manualServerUpdateCommand(targetVersion: string): string { + return `npx t3@${targetVersion}`; +} + +/** One sentence telling the user how to resolve version skew for a server, + matched to the update path it offers. */ +export function serverUpdateGuidance( + capability: ServerSelfUpdateCapability | null, + serverLabel: string, +): string { + switch (capability) { + case "boot-service": + case "respawn": + return `Update the ${serverLabel} so they stay in sync.`; + case "desktop-managed": + return `The ${serverLabel} is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.`; + default: + return `Relaunch the ${serverLabel} with the copied command to sync them.`; + } +} + export function buildVersionMismatchDismissalKey( environmentId: EnvironmentId, mismatch: Pick, diff --git a/docs/README.md b/docs/README.md index db32ff8468f..fe473094bbc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,19 @@ # Documentation - [Getting started](./getting-started/quick-start.md) -- [Architecture](./architecture/overview.md) +- Architecture + - [Overview](./architecture/overview.md) + - [Connection runtime](./architecture/connection-runtime.md) + - [Remote environments](./architecture/remote.md) + - [Server updates](./architecture/server-updates.md) +- User guides + - [Background service](./user/background-service.md) + - [Remote access](./user/remote-access.md) + - [Keeping T3 Code in sync](./user/server-updates.md) + - [Keybindings](./user/keybindings.md) - [T3 Connect](./cloud/t3-connect-clerk.md) - [Integrations](./integrations/source-control-providers.md) - [Mobile](./mobile/app.md) - [Operations](./operations/ci.md) - [Providers](./providers/codex.md) - [Reference](./reference/encyclopedia.md) -- [User guides](./user/keybindings.md) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a7b777fb773..7983bef377e 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -37,6 +37,8 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS - **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state. +- **Server updates**: A connected environment advertises whether its server can replace itself. When client and server versions differ, the browser selects an automatic, desktop-managed, or manual update path without changing connection ownership. See [Server Update Architecture](./server-updates.md). + ## Event Lifecycle ### Startup and client connect diff --git a/docs/architecture/remote.md b/docs/architecture/remote.md index 75274095a12..fc4fc0b4065 100644 --- a/docs/architecture/remote.md +++ b/docs/architecture/remote.md @@ -331,6 +331,21 @@ In all of those cases, the `ExecutionEnvironment` is the same kind of thing. Only the launch and access paths differ. +## Version Coordination + +Remote environments may stay online while web, desktop, or mobile clients move to a newer release. +The environment descriptor therefore carries the running server version and may advertise a safe +replacement path. The web and desktop UI use that information to show the appropriate action +without making the connection transport responsible for process management. + +Published CLI servers on supported hosts can install and hand off to the client's exact version. A +desktop-managed backend instead points the user to the desktop app on that machine, while older or +unsupported servers fall back to a manual relaunch. The existing connection supervisor owns the +disconnect and reconnect just as it would for any other involuntary socket close. + +See [Server Update Architecture](./server-updates.md) for capability detection, installation safety, +and restart sequencing. + ## Security model Remote support must assume that some environments will be reachable over untrusted networks. diff --git a/docs/architecture/server-updates.md b/docs/architecture/server-updates.md new file mode 100644 index 00000000000..2e8c9d6ef61 --- /dev/null +++ b/docs/architecture/server-updates.md @@ -0,0 +1,121 @@ +# Server Update Architecture + +T3 Code can update a connected server to the exact version of the client that detected version +drift. This path exists primarily for remote environments, where the user may not have a terminal +open on the server machine. + +The feature has three boundaries: + +- the server advertises whether and how it can be replaced; +- the client chooses the matching user action; +- the server installs and verifies the replacement before handing off the process. + +## Detection and Presentation + +`ExecutionEnvironmentDescriptor` includes the server version and an optional +`capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after +loading server config. + +The optional capability is intentionally backward compatible. An older server does not know about +the field, so a missing value means the client must offer a manual relaunch instead of sending an +unknown RPC. + +The shared `ServerUpdateAction` is rendered in both user-facing version-drift surfaces: + +- the conversation banner in `ChatView`; +- primary and saved environment rows in **Settings** → **Connections**. + +Both surfaces target the client's exact version. When the reconnected server reports that version, +the mismatch and action disappear. + +## Capability Selection + +The server resolves its capability once at startup and publishes it in the environment descriptor. + +| Advertised value | Process shape | Client behavior | +| ----------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `boot-service` | Linux server running under the T3-managed systemd user service | Call the update RPC; the service unit is replaced and restarted. | +| `respawn` | Published npm CLI running in the foreground on macOS or Linux | Call the update RPC; the process hands off to a detached replacement. | +| `desktop-managed` | Backend supervised by the desktop app | Tell the user to update the desktop app on the server machine. | +| absent | Older server, development checkout, Windows foreground process, or an unrecognized supervisor | Offer the exact manual relaunch command. | + +Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must +never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an +unrecognized systemd unit does not claim the foreground respawn path because its supervisor could +bring the old version back. + +## Update Flow + +```mermaid +flowchart TD + A[Client detects different versions] --> B{Advertised update path} + B -->|desktop-managed| C[Update desktop app on server machine] + B -->|missing| D[Copy exact manual relaunch command] + B -->|boot-service or respawn| E[server.updateServer] + E --> F[Install exact t3 version in pinned runtime] + F --> G[Run version preflight] + G -->|fails| H[Remove failed runtime and keep current server] + G -->|passes| I{Handoff method} + I -->|boot-service| J[Rewrite and restart T3 systemd unit] + I -->|respawn| K[Start delayed replacement and exit current process] + J --> L[Client reconnects] + K --> L +``` + +`server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its +payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as +`latest` and `nightly` are rejected. + +The update service permits one update at a time. It installs `t3@` under +`/runtime/versions/` and writes an install-complete sentinel only after npm exits +successfully. Boot-service setup and self-update share the same process-wide installation lock, so +they cannot mutate a pinned runtime concurrently. + +Before any restart, the current Node executable runs the replacement with `--version`. A failed +install, failed preflight, or wrong reported version leaves the current server running. A failed +preflight also removes the candidate runtime so retrying the same version performs a clean install. + +## Host Service Lifecycle + +The systemd user service is a host lifecycle concern, not a T3 Connect resource. The standalone +`t3 service install`, `uninstall`, `update`, and `status` commands own it. Install and update both +reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and +activates the latest CLI release without requiring a connected client. + +The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation +operation as `t3 service install`. Connect logout only disables cloud access and clears its +authorization; it does not uninstall the host service. + +## Process Handoff + +For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified +runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous +unit before returning an error. + +For `respawn`, the server starts a detached, delayed replacement that replays the original CLI +arguments. It then acknowledges the request and schedules the current process to exit. The delays +give the acknowledgement time to cross direct or relayed connections before the socket closes. + +There is no separate progress stream. The update request remains pending while npm installs and the +client shows a disabled update action. A restart can interrupt the request normally; the connection +runtime keeps the environment registered and reconnects through its usual retry path. After an +acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a +safety timeout releases it. If a boot-service restart closes the connection before acknowledgement, +the UI releases the interrupted action without reporting a false update failure and lets reconnect +and the next version check determine the result. + +## Release Invariant + +The exact client version must exist as the `t3` npm package before a client carrying that version is +published. The release workflow therefore makes the GitHub release depend on CLI publication, and +the hosted web deployment depends on that release. See [Release Checklist](../operations/release.md#server-self-update-release-invariant). + +## Source Map + +- Capability contract: `packages/contracts/src/environment.ts` +- Update RPC contract: `packages/contracts/src/server.ts` and `packages/contracts/src/rpc.ts` +- Capability detection and handoff: `apps/server/src/cloud/selfUpdate.ts` +- Host service commands: `apps/server/src/cli/service.ts` +- Pinned runtime installation: `apps/server/src/cloud/pinnedRuntime.ts` +- Client version comparison: `apps/web/src/versionSkew.ts` +- Shared update action: `apps/web/src/components/ServerUpdateAction.tsx` diff --git a/docs/cloud/t3-connect-clerk.md b/docs/cloud/t3-connect-clerk.md index 3fd1943f7dc..2fe48243a59 100644 --- a/docs/cloud/t3-connect-clerk.md +++ b/docs/cloud/t3-connect-clerk.md @@ -92,6 +92,9 @@ connector, and attempts to revoke the relay-side environment record. It retains authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect logout` performs the same cleanup and removes the stored CLI authorization. +The background service has an independent lifecycle. Connect setup may offer to install it, but +logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`. + The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, forward that port before running `t3 connect login` or `t3 connect link`: diff --git a/docs/operations/release.md b/docs/operations/release.md index 76f787dc023..9e908550054 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -156,6 +156,26 @@ One-time Vercel dashboard setup: - Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. - Does not commit version bumps back to `main`. +## Server self-update release invariant + +Connected servers update to the client's exact version, not to an npm dist-tag. Every released +desktop or hosted client version must therefore have a matching `t3@` package available on +npm before users can receive that client. + +The workflow enforces this ordering: + +1. `publish_cli` publishes the exact stable or nightly version to npm. +2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. +3. `deploy_web` depends on `release` before moving the hosted channel to the new client. + +Preserve these dependencies when changing the release graph. Publishing a client first would leave +the **Update server** action targeting a package version that does not exist yet. + +For a release smoke test, confirm `npm view t3@ version` returns the expected version, then +connect the new client to a server on the previous version and verify that the update action +reconnects to the matching server. Test one automatic path and the manual or desktop-managed +guidance when those environments are available. + ## Desktop auto-update notes - Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`. @@ -293,6 +313,7 @@ Checklist: 5. Verify workflow steps: - preflight passes - all matrix builds pass + - `publish_cli` publishes the exact release version before the release job - release job uploads expected files 6. Smoke test downloaded artifacts. diff --git a/docs/user/background-service.md b/docs/user/background-service.md new file mode 100644 index 00000000000..8f1f68dc065 --- /dev/null +++ b/docs/user/background-service.md @@ -0,0 +1,42 @@ +# Running T3 Code in the Background + +On a Linux host, T3 Code can run as a background service for your user. It starts when the machine +boots and keeps running after you log out. + +## Manage the Service + +Install it with the latest T3 Code release: + +```sh +npx t3@latest service install +``` + +Check whether it is installed: + +```sh +npx t3@latest service status +``` + +Update or repair it: + +```sh +npx t3@latest service update +``` + +Stop it and remove it from startup: + +```sh +npx t3@latest service uninstall +``` + +Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. + +## Using It with T3 Connect + +T3 Connect may offer to install the service during setup so the host stays reachable after you log +out. This is only an onboarding shortcut: the service and T3 Connect are managed separately. + +Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer +want T3 Code to start in the background. + +The background service currently requires Linux with systemd. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 56510e62890..d6ff00bee1f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -152,6 +152,19 @@ With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually. +## Updating a Remote Server + +When the T3 Code web or desktop app and a remote server use different versions, a warning appears in +the conversation and in **Settings** → **Connections**. Follow the action shown there: T3 Code may +be able to update and reconnect the server for you, or it may ask you to update the desktop app or +run a copied command on the server machine. + +Finish active work before updating because the server restarts briefly. For step-by-step guidance, +see [Keeping T3 Code in Sync](./server-updates.md). + +On a Linux host, you can keep the server running after logout and manage it independently of the +connection method. See [Running T3 Code in the Background](./background-service.md). + ## How Pairing Works The remote device does not need a long-lived secret up front. diff --git a/docs/user/server-updates.md b/docs/user/server-updates.md new file mode 100644 index 00000000000..6a8e33977b7 --- /dev/null +++ b/docs/user/server-updates.md @@ -0,0 +1,56 @@ +# Keeping T3 Code in Sync + +The T3 Code web or desktop app and the server it connects to work best when they use the same +version. If they do not match, T3 Code shows a warning with the right update option for that server. + +## Where to Find the Update + +You may see the warning in either of these places: + +- above the message box in the current conversation +- **Settings** → **Connections**, beside the affected connection + +Dismissing the conversation warning only hides that reminder for those two versions. It does not +update the server, and the version difference remains visible in Connections. + +## Before You Update + +Let active agent work and terminal commands finish first. Updating restarts the server, so the +connection will disappear briefly and work that is still running may be interrupted. + +The update does not remove saved threads, settings, or project files. + +## Choose the Action You See + +| Action | What to do | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Update server** | Select the button and leave T3 Code open. It prepares the matching version, restarts the server, and reconnects automatically. This can take several minutes. | +| **Update the desktop app** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. | +| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. | + +The available action depends on how that server was started. T3 Code does not update connected +servers silently in the background. + +If the server uses the T3 Code background service, you can also update it directly on the host: + +```sh +npx t3@latest service update +``` + +See [Running T3 Code in the Background](./background-service.md) for install, status, and removal +commands. + +## After the Update + +Keep the web or desktop app open while the server restarts. When it reconnects with the matching +version, the warning and update action disappear. + +If the client reports a timeout, the server may still be finishing the update. Wait a minute, then +reconnect or open **Settings** → **Connections** again. If the warning remains: + +1. Retry the offered action once. +2. Make sure you updated the machine named in the warning, not only the device you are using. +3. For a command-line server, relaunch it with `npx t3@`, replacing + `` with the client version shown in the warning. + +For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md). diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7306a0a1071..ea7f5fb6d75 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -321,6 +321,12 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + updateServer: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:update-server", + tag: WS_METHODS.serverUpdateServer, + scheduler: configScheduler, + concurrency: configConcurrency, + }), upsertKeybinding: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:upsert-keybinding", tag: WS_METHODS.serverUpsertKeybinding, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6fc0c914d8a..936b97f6c2d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -20,6 +20,23 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ }); export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; +/** How a server can replace itself with another version when asked over RPC: + "boot-service" rewrites the systemd user unit and restarts it; "respawn" + installs the target version and respawns the foreground process. */ +export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); +export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; + +/** What update path a client should offer for a server: one of the RPC + self-update methods above, or "desktop-managed" when the backend's + version belongs to the T3 Code desktop app supervising it — updating the + app on that machine is the only way to update the server. */ +export const ServerSelfUpdateCapability = Schema.Literals([ + "boot-service", + "respawn", + "desktop-managed", +]); +export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; + export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), @@ -27,6 +44,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** The update path clients should offer for this server. Absent on + servers that must be relaunched manually (dev checkouts, Windows + foreground runs, pre-update servers). */ + serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0356aa1807b..fa2d23b8ef2 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -122,6 +122,9 @@ import { ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, + ServerSelfUpdateError, + ServerSelfUpdateInput, + ServerSelfUpdateResult, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, @@ -205,6 +208,7 @@ export const WS_METHODS = { serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", + serverUpdateServer: "server.updateServer", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", @@ -279,6 +283,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), }); +export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { + payload: ServerSelfUpdateInput, + success: ServerSelfUpdateResult, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), +}); + export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -693,6 +703,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, + WsServerUpdateServerRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d99b8e95a6..69699c7a839 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { IsoDateTime, @@ -574,3 +574,30 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass()( + "ServerSelfUpdateError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Server update failed: ${this.reason}`; + } +} From 593289c3c771ec1987b3f904eb95305d7ccaecf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 23:51:31 +0200 Subject: [PATCH 3/5] Refine light-mode sidebar surfaces (#4268) Co-authored-by: Claude Fable 5 Co-authored-by: codex --- apps/mobile/src/features/home/HomeHeader.tsx | 149 ++- .../src/features/home/HomeRouteScreen.tsx | 30 +- apps/mobile/src/features/home/HomeScreen.tsx | 175 ++-- .../home/home-list-filter-menu.test.ts | 44 + .../features/home/home-list-filter-menu.ts | 131 ++- .../features/home/home-list-options.test.ts | 3 + .../src/features/home/home-list-options.ts | 5 +- .../features/home/thread-swipe-actions.tsx | 36 +- .../threads/ThreadNavigationSidebar.tsx | 495 ++++++++- .../features/threads/thread-list-v2-items.tsx | 294 ++++-- .../src/features/threads/threadListV2.ts | 6 + apps/mobile/src/native/StackHeader.tsx | 11 +- apps/web/src/components/AppSidebarLayout.tsx | 9 +- apps/web/src/components/ChatView.tsx | 1 + .../components/CommandPalette.logic.test.ts | 28 + .../src/components/CommandPalette.logic.ts | 18 +- apps/web/src/components/CommandPalette.tsx | 90 +- .../src/components/CommandPaletteResults.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/SidebarV2.tsx | 963 ++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../components/chat/ModelPickerContent.tsx | 6 +- .../clerk/T3ConnectSidebarSignIn.tsx | 2 +- .../settings/SettingsSidebarNav.tsx | 10 +- .../components/settings/settingsLayout.tsx | 2 +- .../src/components/sidebar/SidebarChrome.tsx | 9 +- apps/web/src/components/ui/command.tsx | 10 +- apps/web/src/components/ui/sidebar.tsx | 20 +- apps/web/src/index.css | 144 +-- 30 files changed, 1825 insertions(+), 878 deletions(-) create mode 100644 apps/mobile/src/features/home/home-list-filter-menu.test.ts diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 3a0342a8214..cf9cf378edd 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -4,6 +4,8 @@ import type { SidebarThreadSortOrder, } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -14,6 +16,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -21,6 +24,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, + type HomeListFilterMenuProject, } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, @@ -33,13 +37,16 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; @@ -59,11 +66,24 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } +/** Thread List v2 lays the list out in fixed creation order, so the + sort/group filter controls would be silently ignored — hide them and + key the "customized" icon state off the environment filter alone. */ +function useThreadListV2FilterGate() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + return ( + AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true + ); +} + function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const menuActions = useMemo( () => [ { @@ -82,40 +102,67 @@ function AndroidHomeHeader(props: HomeHeaderProps) { })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectGroupingMode === option.value), - })), - }, + ...(props.projects.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(props.selectedProjectKey === null), + }, + ...props.projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(props.selectedProjectKey === project.key), + })), + ], + }, + ] satisfies MenuAction[])), + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectSortOrder === option.value), + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.threadSortOrder === option.value), + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectGroupingMode === option.value), + })), + }, + ] satisfies MenuAction[])), ], [ props.environments, props.projectGroupingMode, props.projectSortOrder, + props.projects, props.selectedEnvironmentId, + props.selectedProjectKey, props.threadSortOrder, + threadListV2Enabled, ], ); const handleMenuAction = useCallback( @@ -137,6 +184,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id === "project:all") { + props.onProjectChange(null); + return; + } + + if (id.startsWith("project:")) { + const projectKey = id.slice("project:".length); + if (props.projects.some((project) => project.key === projectKey)) { + props.onProjectChange(projectKey); + } + return; + } + const projectSort = PROJECT_SORT_OPTIONS.find( (option) => id === `project-sort:${option.value}`, ); @@ -255,17 +315,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); + const filterMenu = buildHomeListFilterMenu({ + ...props, + listOrganization: !threadListV2Enabled, + }); return ( <> + {props.projects.length > 0 ? ( + + Project + props.onProjectChange(null)} + subtitle="Show threads from every project" + > + All projects + + {props.projects.map((project) => ( + props.onProjectChange(project.key)} + > + {project.label} + + ))} + + ) : null} + Sort projects {PROJECT_SORT_OPTIONS.map((option) => ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 502d2bab1b5..d4b19cdb0fb 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,9 +1,10 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -58,6 +59,28 @@ export function HomeRouteScreen() { setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectFilterOptions = useMemo( + () => + projects + .filter( + (project) => + selectedEnvironmentId === null || project.environmentId === selectedEnvironmentId, + ) + .map((project) => ({ + key: scopedProjectKey(project.environmentId, project.id), + label: project.title, + })), + [projects, selectedEnvironmentId], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. @@ -90,12 +113,15 @@ export function HomeRouteScreen() { navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} @@ -115,6 +141,7 @@ export function HomeRouteScreen() { onSettleThread={settleThread} onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} + onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) } @@ -151,6 +178,7 @@ export function HomeRouteScreen() { savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} + selectedProjectKey={selectedProjectKey} threads={threads} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d1339a9bb91..41180c48643 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -38,7 +38,12 @@ import { ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { ThreadListV2Row } from "../threads/thread-list-v2-items"; -import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; +import { + buildThreadListV2Items, + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListV2Item, +} from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -65,11 +70,13 @@ interface HomeScreenProps { readonly environments: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; @@ -91,10 +98,6 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; -// v2 settled-tail paging: recent history is the common lookup; the deep -// tail stays behind an explicit Show more. -const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; -const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -169,80 +172,6 @@ function HomeTopContentSpacer() { return ; } -function ThreadListV2ProjectScope(props: { - readonly projects: ReadonlyArray; - readonly selectedKey: string | null; - readonly onChange: (key: string | null) => void; -}) { - if (props.projects.length === 0) return null; - - return ( - - {props.projects.length > 1 ? ( - props.onChange(null)} - className={cn( - "min-h-8 items-center justify-center rounded-lg border px-3", - props.selectedKey === null - ? "border-border bg-subtle-strong" - : "border-black/15 dark:border-white/15", - )} - > - All - - ) : null} - {props.projects.map((project) => { - const key = scopedProjectKey(project.environmentId, project.id); - const selected = props.selectedKey === key; - return ( - props.onChange(selected ? null : key)} - className={cn( - "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3", - selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15", - )} - > - - - {project.title} - - - ); - })} - - ); -} - /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { @@ -314,12 +243,51 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); + const scopedProject = useMemo( + () => + props.selectedProjectKey === null + ? null + : (props.projects.find( + (project) => + scopedProjectKey(project.environmentId, project.id) === props.selectedProjectKey && + (props.selectedEnvironmentId === null || + project.environmentId === props.selectedEnvironmentId), + ) ?? null), + [props.projects, props.selectedEnvironmentId, props.selectedProjectKey], + ); + const scopedProjects = useMemo( + () => (scopedProject === null ? props.projects : [scopedProject]), + [props.projects, scopedProject], + ); + const scopedThreads = useMemo( + () => + scopedProject === null + ? props.threads + : props.threads.filter( + (thread) => + thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id, + ), + [props.threads, scopedProject], + ); + const scopedPendingTasks = useMemo( + () => + scopedProject === null + ? props.pendingTasks + : props.pendingTasks.filter( + (pendingTask) => + pendingTask.message.environmentId === scopedProject.environmentId && + pendingTask.creation.projectId === scopedProject.id, + ), + [props.pendingTasks, scopedProject], + ); + const projectGroups = useMemo( () => buildHomeThreadGroups({ - projects: props.projects, - threads: props.threads, - pendingTasks: props.pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: props.projectSortOrder, @@ -327,14 +295,14 @@ export function HomeScreen(props: HomeScreenProps) { projectGroupingMode: props.projectGroupingMode, }), [ - props.pendingTasks, props.projectGroupingMode, - props.projects, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, - props.threads, + scopedPendingTasks, + scopedProjects, + scopedThreads, ], ); @@ -365,7 +333,8 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); - const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState(null); + const v2ProjectScopeKey = props.selectedProjectKey; + const setV2ProjectScopeKey = props.onProjectChange; const v2ScopeProjects = useMemo( () => props.selectedEnvironmentId === null @@ -382,12 +351,6 @@ export function HomeScreen(props: HomeScreenProps) { ) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); - useEffect(() => { - if (v2ProjectScopeKey !== null && v2ScopedProject === null) { - setV2ProjectScopeKey(null); - } - }, [v2ProjectScopeKey, v2ScopedProject]); - // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. // Settled threads stay in the live shell stream (settled ≠ archived), so @@ -442,6 +405,10 @@ export function HomeScreen(props: HomeScreenProps) { const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); useEffect(() => { if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); }, [threadListV2Enabled]); @@ -509,6 +476,11 @@ export function HomeScreen(props: HomeScreenProps) { (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), )?.driver ?? null } + environmentLabel={ + Object.keys(props.savedConnectionsById).length > 1 + ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) + : null + } onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} @@ -535,6 +507,7 @@ export function HomeScreen(props: HomeScreenProps) { projectCwdByKey, props.onArchiveThread, props.onSelectThread, + props.savedConnectionsById, serverConfigs, settlementEnvironmentIds, ], @@ -731,14 +704,11 @@ export function HomeScreen(props: HomeScreenProps) { pendingTask.creation.projectId === v2ScopedProject.id)) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); + // Project scoping lives in the header filter menu (no inline chip row on + // mobile — the menu is the one filter surface). const v2ListHeader = ( <> {listHeader} - {v2PendingTasks.map((pendingTask, index) => ( + ) : scopedProject !== null ? ( + ) : selectedEnvironmentLabel ? ( 0 ? ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts new file mode 100644 index 00000000000..916e32671a1 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { buildHomeListFilterMenu } from "./home-list-filter-menu"; + +describe("buildHomeListFilterMenu", () => { + it("adds a project scope submenu that selects and clears the same scope as the chips", () => { + const onProjectChange = vi.fn(); + const menu = buildHomeListFilterMenu({ + environments: [], + projects: [ + { key: "environment-1:project-1", label: "Codething" }, + { key: "environment-1:project-2", label: "Website" }, + ], + selectedEnvironmentId: null, + selectedProjectKey: "environment-1:project-1", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + projectGroupingMode: "repository", + onEnvironmentChange: vi.fn(), + onProjectChange, + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + onProjectGroupingModeChange: vi.fn(), + }); + + const projectMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Project", + ); + expect(projectMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "All projects", state: "off" }, + { title: "Codething", state: "on" }, + { title: "Website", state: "off" }, + ], + }); + if (projectMenu?.type !== "submenu") throw new Error("Expected project submenu"); + + projectMenu.items[0]?.onPress(); + projectMenu.items[2]?.onPress(); + expect(onProjectChange).toHaveBeenNthCalledWith(1, null); + expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2"); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index 46ea639677b..73fda2f5d03 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -16,6 +16,11 @@ export interface HomeListFilterMenuEnvironment { readonly label: string; } +export interface HomeListFilterMenuProject { + readonly key: string; + readonly label: string; +} + type HomeListFilterMenuAction = { readonly type: "action"; readonly title: string; @@ -37,15 +42,22 @@ export interface HomeListFilterMenu { export function buildHomeListFilterMenu(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onOpenSettings?: () => void; + /** False hides the sort/group submenus. Thread List v2 uses a fixed + creation-order layout, so offering those controls while it silently + ignores them would be a lie; the environment filter still applies. */ + readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -57,61 +69,86 @@ export function buildHomeListFilterMenu(props: { }); } - items.push( - { + items.push({ + type: "submenu", + title: "Environment", + items: [ + { + type: "action", + title: "All environments", + subtitle: "Show threads from every environment", + state: props.selectedEnvironmentId === null ? "on" : "off", + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + type: "action" as const, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : ("off" as const), + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }); + + if (props.projects.length > 0) { + items.push({ type: "submenu", - title: "Environment", + title: "Project", items: [ { type: "action", - title: "All environments", - subtitle: "Show threads from every environment", - state: props.selectedEnvironmentId === null ? "on" : "off", - onPress: () => props.onEnvironmentChange(null), + title: "All projects", + subtitle: "Show threads from every project", + state: props.selectedProjectKey === null ? "on" : "off", + onPress: () => props.onProjectChange(null), }, - ...props.environments.map((environment) => ({ + ...props.projects.map((project) => ({ type: "action" as const, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), + title: project.label, + state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + onPress: () => props.onProjectChange(project.key), })), ], - }, - { - type: "submenu", - title: "Sort projects", - items: PROJECT_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.projectSortOrder === option.value ? "on" : "off", - onPress: () => props.onProjectSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Sort threads", - items: THREAD_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.threadSortOrder === option.value ? "on" : "off", - onPress: () => props.onThreadSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Group projects", - items: PROJECT_GROUPING_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - subtitle: option.subtitle, - state: props.projectGroupingMode === option.value ? "on" : "off", - onPress: () => props.onProjectGroupingModeChange(option.value), - })), - }, - ); + }); + } + + if (props.listOrganization !== false) { + items.push( + { + type: "submenu", + title: "Sort projects", + items: PROJECT_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.projectSortOrder === option.value ? "on" : "off", + onPress: () => props.onProjectSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Sort threads", + items: THREAD_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.threadSortOrder === option.value ? "on" : "off", + onPress: () => props.onThreadSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Group projects", + items: PROJECT_GROUPING_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + subtitle: option.subtitle, + state: props.projectGroupingMode === option.value ? "on" : "off", + onPress: () => props.onProjectGroupingModeChange(option.value), + })), + }, + ); + } return { title: "Thread list options", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 788be25906b..651a64f6f83 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -27,5 +27,8 @@ describe("home list options", () => { hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), ).toBe(true); expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true); + expect( + hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 64881034306..919cec55ef1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -93,13 +93,16 @@ export function HomeListOptionsProvider({ children }: PropsWithChildren) { return createElement(HomeListOptionsContext, { value }, children); } -export function hasCustomHomeListOptions(options: HomeListOptions): boolean { +export function hasCustomHomeListOptions( + options: HomeListOptions & { readonly selectedProjectKey?: string | null }, +): boolean { const defaultProjectSortOrder = DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; return ( options.selectedEnvironmentId !== null || + (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.projectSortOrder !== defaultProjectSortOrder || options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 666169f3039..186c606ae8f 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -36,6 +36,8 @@ import { AppText as Text } from "../../components/AppText"; const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; +const COMPACT_ACTION_CIRCLE_SIZE = 28; +const COMPACT_ACTION_ICON_SIZE = 13; export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; export const THREAD_SWIPE_SPRING = { @@ -163,6 +165,9 @@ export function useSwipeableScrollGate(options?: { export function ThreadSwipeable(props: { readonly backgroundColor: ColorValue; readonly children: (close: () => void) => ReactNode; + /** Uses action visuals that fit inside compact 44pt rows. The press target + * still spans the row's full height and width. */ + readonly compactActions?: boolean; readonly containerStyle?: StyleProp; /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; @@ -258,6 +263,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( ["name"]; @@ -293,6 +300,8 @@ function SwipeActionButton(props: { readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; }) { + const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; + const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); @@ -324,7 +333,7 @@ function SwipeActionButton(props: { return { transform: [{ translateX: -stretch }], - width: ACTION_CIRCLE_SIZE + stretch, + width: circleSize + stretch, }; }); const iconStyle = useAnimatedStyle(() => { @@ -386,13 +395,13 @@ function SwipeActionButton(props: { width: "100%", })} > - + - + - + {props.label} @@ -434,6 +443,7 @@ function SwipeActionButton(props: { export function ThreadSwipeActions(props: { readonly backgroundColor: ColorValue; + readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeThreshold: number; readonly onDelete: () => void; @@ -466,6 +476,7 @@ export function ThreadSwipeActions(props: { (null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -193,19 +224,77 @@ function ThreadNavigationSidebarPane( setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectFilterOptions = useMemo( + () => + projects + .filter( + (project) => + options.selectedEnvironmentId === null || + project.environmentId === options.selectedEnvironmentId, + ) + .map((project) => ({ + key: scopedProjectKey(project.environmentId, project.id), + label: project.title, + })), + [options.selectedEnvironmentId, projects], + ); + const selectedProject = useMemo( + () => + selectedProjectKey === null + ? null + : (projects.find( + (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, + ) ?? null), + [projects, selectedProjectKey], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); + const scopedProjects = useMemo( + () => (selectedProject === null ? projects : [selectedProject]), + [projects, selectedProject], + ); + const scopedThreads = useMemo( + () => + selectedProject === null + ? threads + : threads.filter( + (thread) => + thread.environmentId === selectedProject.environmentId && + thread.projectId === selectedProject.id, + ), + [selectedProject, threads], + ); + const scopedPendingTasks = useMemo( + () => + selectedProject === null + ? pendingTasks + : pendingTasks.filter( + (pendingTask) => + pendingTask.message.environmentId === selectedProject.environmentId && + pendingTask.creation.projectId === selectedProject.id, + ), + [pendingTasks, selectedProject], + ); const groups = useMemo( () => buildHomeThreadGroups({ - projects, - threads, - pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), - [options, pendingTasks, projects, props.searchQuery, threads], + [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap @@ -237,6 +326,153 @@ function ThreadNavigationSidebarPane( } return map; }, [projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [projects]); + + // Thread List v2 (beta) support — same model as the compact Home list + // (HomeScreen.tsx): flat creation-order card block + settled recency tail. + // PR states stream in per-row; merged/closed PRs auto-settle their thread + // on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // now ticks per minute so the inactivity auto-settle boundary is actually + // crossed while the pane stays open; without a clock dependency the + // partition memoizes a frozen "now". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + return buildThreadListV2Items({ + threads: threads.filter((thread) => thread.archivedAt === null), + environmentId: options.selectedEnvironmentId, + projectRef: + selectedProject === null + ? null + : { + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + }); + }, [ + changeRequestStateByKey, + nowMinute, + options.selectedEnvironmentId, + props.searchQuery, + settledVisibleCount, + settlementEnvironmentIds, + threadListV2Enabled, + threads, + selectedProject, + ]); + const listItems = useMemo(() => { + if (!threadListV2Enabled) return listLayout.items; + // Queued offline tasks render above the thread rows (mirrors the + // compact Home v2 list): they are not thread shells, so the v2 item + // builder never sees them, but they must stay visible and deletable + // while their environment is offline. Same environment scope and + // search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = pendingTasks.filter( + (pendingTask) => + (options.selectedEnvironmentId === null || + pendingTask.message.environmentId === options.selectedEnvironmentId) && + (selectedProject === null || + (pendingTask.message.environmentId === selectedProject.environmentId && + pendingTask.creation.projectId === selectedProject.id)) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ + type: "v2-pending-task" as const, + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + isLast: index === v2PendingTasks.length - 1, + })); + for (const item of threadListV2Layout.items) { + items.push({ + type: "v2-thread" as const, + key: scopedThreadKey(item.thread.environmentId, item.thread.id), + item, + }); + } + if (threadListV2Layout.hiddenSettledCount > 0) { + items.push({ + type: "v2-show-more", + key: "v2-show-more", + hiddenCount: threadListV2Layout.hiddenSettledCount, + }); + } + return items; + }, [ + listLayout.items, + options.selectedEnvironmentId, + pendingTasks, + props.searchQuery, + selectedProject, + threadListV2Enabled, + threadListV2Layout, + ]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ @@ -260,36 +496,64 @@ function ThreadNavigationSidebarPane( })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: options.projectSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: options.threadSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - subtitle: option.subtitle, - state: options.projectGroupingMode === option.value ? "on" : "off", - })), - }, + ...(projectFilterOptions.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + subtitle: "Show threads from every project", + state: selectedProjectKey === null ? "on" : "off", + }, + ...projectFilterOptions.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + })), + ], + }, + ] satisfies MenuAction[])), + // v2 lays the list out in fixed creation order — offering sort/group + // controls it silently ignores would be a lie. Environment still + // scopes the v2 partition, so it stays. + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: options.projectSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: options.threadSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + subtitle: option.subtitle, + state: options.projectGroupingMode === option.value ? "on" : "off", + })), + }, + ] satisfies MenuAction[])), ], - [environments, options], + [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -305,6 +569,17 @@ function ThreadNavigationSidebarPane( if (environment) setSelectedEnvironmentId(environment.environmentId); return; } + if (event === "project:all") { + setSelectedProjectKey(null); + return; + } + if (event.startsWith("project:")) { + const projectKey = event.slice("project:".length); + if (projectFilterOptions.some((project) => project.key === projectKey)) { + setSelectedProjectKey(projectKey); + } + return; + } const projectSort = PROJECT_SORT_OPTIONS.find( (option) => `project-sort:${option.value}` === event, ); @@ -326,6 +601,7 @@ function ThreadNavigationSidebarPane( }, [ environments, + projectFilterOptions, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, @@ -382,7 +658,44 @@ function ThreadNavigationSidebarPane( onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - const listExtraData = props.selectedThreadKey ?? ""; + const listExtraData = useMemo( + () => ({ + selectedThreadKey: props.selectedThreadKey ?? "", + savedConnectionsById, + serverConfigs, + }), + [props.selectedThreadKey, savedConnectionsById, serverConfigs], + ); + const sidebarItemsAreEqual = useCallback( + (previous: SidebarListItem, item: SidebarListItem): boolean => { + if (previous.type === "v2-thread" && item.type === "v2-thread") { + return ( + previous.key === item.key && + previous.item.thread === item.item.thread && + previous.item.variant === item.item.variant && + previous.item.showSettledDivider === item.item.showSettledDivider + ); + } + if (previous.type === "v2-show-more" && item.type === "v2-show-more") { + return previous.hiddenCount === item.hiddenCount; + } + if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { + return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; + } + if ( + previous.type === "v2-thread" || + previous.type === "v2-show-more" || + previous.type === "v2-pending-task" || + item.type === "v2-thread" || + item.type === "v2-show-more" || + item.type === "v2-pending-task" + ) { + return false; + } + return homeListItemsAreEqual(previous, item); + }, + [], + ); const focusSearch = useCallback(() => { const focus = () => { if (props.nativeChrome) { @@ -401,8 +714,78 @@ function ThreadNavigationSidebarPane( }, [props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( - ({ item }: { readonly item: HomeListItem }) => { + ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { + case "v2-pending-task": + return ( + + ); + case "v2-thread": { + const thread = item.item.thread; + const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + return ( + + provider.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + )?.driver ?? null + } + environmentLabel={ + Object.keys(savedConnectionsById).length > 1 + ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null + } + pane="sidebar" + selected={ + scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey + } + fullSwipeWidth={props.width - 20} + onSelectThread={handleSelectThread} + onDeleteThread={confirmDeleteThread} + onArchiveThread={archiveThread} + settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={projectCwdByKey.get(scopeKey) ?? null} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={sidebarScrollGesture} + /> + ); + } + case "v2-show-more": + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({item.hiddenCount} settled hidden) + + + ); case "header": return ( buildHomeListFilterMenu({ environments, + projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, + selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, onEnvironmentChange: setSelectedEnvironmentId, + onProjectChange: setSelectedProjectKey, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, onProjectGroupingModeChange: setProjectGroupingMode, + listOrganization: !threadListV2Enabled, }), [ environments, options, + projectFilterOptions, + selectedProjectKey, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, + threadListV2Enabled, ], ); const nativeHeaderItems = useMemo( @@ -531,7 +933,9 @@ function ThreadNavigationSidebarPane( ? "Loading threads…" : props.searchQuery.trim().length > 0 ? "No matching threads" - : "No threads yet"} + : selectedProject !== null + ? `No threads in ${selectedProject.title}` + : "No threads yet"} ); @@ -539,6 +943,7 @@ function ThreadNavigationSidebarPane( return ( <> item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} @@ -625,12 +1030,12 @@ function ThreadNavigationSidebarPane( item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} contentContainerStyle={[ diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 9dd41f3cd60..63eb9a4d9f3 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -19,8 +19,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; /** - * Thread List v2 rows mirror the web sidebar's compact tonal cards and - * receded settled tail while retaining native swipe and long-press actions. + * Thread List v2 renders one flat native list: rich edge-to-edge rows for + * active work and a receded settled tail, all with native swipe and + * long-press actions. State reads through colored status labels and text + * hierarchy rather than card fills. */ const MONO_FONT = Platform.select({ @@ -29,12 +31,15 @@ const MONO_FONT = Platform.select({ default: "monospace", }); +// Status hues follow the system-wide convention set by sidebar v1 and the +// Live Activity/widgets (amber approval, indigo input, sky working) so a +// thread reads the same color everywhere it surfaces. const STATUS_LABEL_BY_STATUS: Partial< Record > = { approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, - input: { label: "Input", className: "text-amber-700 dark:text-amber-300" }, - working: { label: "Working", className: "text-blue-600 dark:text-blue-400" }, + input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, + working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, }; @@ -60,10 +65,20 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; -export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { +/** Rounded-row radius shared with the v1 sidebar rows. */ +const SIDEBAR_V2_ROW_RADIUS = 12; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: { + readonly pane?: "screen" | "sidebar"; +}) { const borderColor = useThemeColor("--color-border"); return ( - + Settled @@ -76,6 +91,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly showSettledDivider: boolean; readonly project: EnvironmentProject | null; readonly providerDriver: string | null; + /** Which machine hosts the thread. Null when only one environment is + connected — repeating the same label on every row is noise. Mirrors + the web sidebar's remote-environment cloud icon, but as text since + phones have no hover tooltips. */ + readonly environmentLabel: string | null; + /** Hosting surface. "screen" (default) renders the compact Home idiom: + flat edge-to-edge rows on the screen background with inset hairlines. + "sidebar" renders the iPad split-view idiom: rounded rows blending + into the drawer surface, selection filled with the accent color — + matching the v1 sidebar rows. */ + readonly pane?: "screen" | "sidebar"; + /** Highlights the thread open in the detail pane (iPad split view). The + compact Home list never sets it — phones navigate away on select. */ + readonly selected?: boolean; + /** Override for narrow panes (iPad sidebar); defaults to window width. */ + readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => void; @@ -117,6 +148,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [onChangeRequestState, prState, threadKey]); const screenColor = useThemeColor("--color-screen"); + const drawerColor = useThemeColor("--color-drawer"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const sidebarPane = props.pane === "sidebar"; + const selected = props.selected === true; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; @@ -174,99 +210,184 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { thread.title, ]); + // The sidebar pane fills selected rows with the accent color (matching the + // v1 sidebar), so every piece of row text needs a white-on-accent variant. + const cardContent = ( + <> + + {props.project ? ( + + ) : null} + + {props.project?.title ?? ""} + + + {statusLabel?.label ?? timeLabel} + + + + {thread.title} + + + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch || props.environmentLabel ? ( + /* "branch · machine" share one truncating line. The machine sits + last so a tight fit cuts the repetitive label, not the branch — + and machine-only fills the row for non-git projects. */ + + {thread.branch ? ( + + {thread.branch} + + ) : null} + {thread.branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + ) : ( + + )} + {pr ? ( + + #{pr.label} + + ) : null} + {props.providerDriver ? ( + + + + ) : null} + + + ); + const rowContent = (close: () => void) => variant === "card" ? ( { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } > - - - - - {props.project ? ( - - ) : null} - - {props.project?.title ?? ""} - - - {statusLabel?.label ?? timeLabel} - - - - {thread.title} - - - {status === "failed" && thread.session?.lastError ? ( - - {thread.session.lastError} - - ) : thread.branch ? ( - - {thread.branch} - - ) : ( - - )} - {props.providerDriver ? ( - - - - ) : null} - - + {sidebarPane ? ( + cardContent + ) : ( + /* Flat native list rows: no tonal containers — colored status + labels and text hierarchy carry state, an inset hairline + separates rows. The opaque screen background stays so swipe + actions reveal behind the row. */ + + {cardContent} + - + )} ) : ( { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } > {/* Settled history recedes: dimmed favicon + muted title. */} - + {props.project ? ( ) : null} - + {thread.title} {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} @@ -292,14 +422,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? : null} + {props.showSettledDivider ? : null} , ): ThreadListV2Status { diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 8a8c355b760..78c87119512 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -142,6 +142,13 @@ function stabilizeOptionFunctions( export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; + /** + * Causes dynamic native header factories to be reapplied when their closed-over + * menu content changes. Factory functions are intentionally stabilized, so + * their source alone cannot capture a menu that was initially empty while + * asynchronous data was loading. + */ + readonly optionsVersion?: unknown; readonly listeners?: Record void>; readonly name?: string; }) { @@ -163,7 +170,7 @@ export function NativeStackScreenOptions(props: { if (!navigation || !stableOptions) { return; } - const signature = optionsSignature(stableOptions); + const signature = optionsSignature([stableOptions, props.optionsVersion]); // Avoid re-entering navigation state when semantically equal options are // reapplied every layout (common when callers pass unstable object literals). if (lastAppliedOptionsSignatureRef.current === signature) { @@ -171,7 +178,7 @@ export function NativeStackScreenOptions(props: { } lastAppliedOptionsSignatureRef.current = signature; navigation.setOptions(stableOptions); - }, [navigation, stableOptions]); + }, [navigation, props.optionsVersion, stableOptions]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 8a713fd9026..ef6b64be9c9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -84,7 +84,7 @@ function SidebarControl() { "pointer-events-auto", isSidebarVisible && stageBackdropVariant && - "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!", + "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", )} aria-label="Toggle main sidebar" /> @@ -164,10 +164,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {

{ + it("assigns positional jump shortcuts to the first nine displayed items", () => { + const items = Array.from({ length: 10 }, (_, index) => ({ + kind: "action" as const, + value: `project-${index + 1}`, + searchTerms: [], + title: `Project ${index + 1}`, + icon: null, + shortcutCommand: "chat.new" as const, + run: async () => undefined, + })); + + expect(enumerateCommandPaletteItems(items).map((item) => item.shortcutCommand)).toEqual([ + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9", + undefined, + ]); + }); +}); + const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..a217d53b5b7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,8 @@ -import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; +import { + type KeybindingCommand, + type FilesystemBrowseEntry, + THREAD_JUMP_KEYBINDING_COMMANDS, +} from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -52,6 +56,18 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +export function enumerateCommandPaletteItems( + items: ReadonlyArray, +): CommandPaletteActionItem[] { + return items.map((item, index) => { + const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index]; + if (shortcutCommand) return { ...item, shortcutCommand }; + + const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item; + return itemWithoutShortcut; + }); +} + export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8c7e6d287ca..bb83dfa5691 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -95,6 +95,7 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, + enumerateCommandPaletteItems, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -112,7 +113,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; -import { resolveShortcutCommand } from "../keybindings"; +import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { Command, CommandDialog, @@ -686,29 +687,30 @@ function OpenCommandPaletteDialog(props: { const projectThreadItems = useMemo( () => - buildProjectActionItems({ - projects, - valuePrefix: "new-thread-in", - shortcutCommand: "chat.new", - icon: (project) => ( - - ), - runProject: async (project) => { - await startNewThreadInProjectFromContext( - { - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }, - scopeProjectRef(project.environmentId, project.id), - ); - }, - }), + enumerateCommandPaletteItems( + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: (project) => ( + + ), + runProject: async (project) => { + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + ), [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], ); @@ -997,7 +999,13 @@ function OpenCommandPaletteDialog(props: { : projectThreadItems; pushPaletteView({ addonIcon: , - groups: [{ value: "projects", label: "Projects", items: prioritized }], + groups: [ + { + value: "projects", + label: "Projects", + items: enumerateCommandPaletteItems(prioritized), + }, + ], }); }, [ clearOpenIntent, @@ -1545,6 +1553,22 @@ function OpenCommandPaletteDialog(props: { } function handleKeyDown(event: KeyboardEvent): void { + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false }, + }); + if (threadJumpIndexFromCommand(command ?? "") !== null) { + const matchingItem = displayedGroups + .flatMap((group) => group.items) + .find((item) => item.shortcutCommand === command); + if (matchingItem) { + event.preventDefault(); + event.stopPropagation(); + executeItem(matchingItem); + return; + } + } + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); void submitAddProjectCloneFlow(); @@ -1855,7 +1879,7 @@ function OpenCommandPaletteDialog(props: { {remoteProjectContext.title} - + {remoteProjectContext.description} @@ -1896,37 +1920,35 @@ function OpenCommandPaletteDialog(props: { - Navigate + Navigate {addProjectCloneFlow?.step === "repository" ? ( Enter - - {remoteProjectButtonLabel ?? "Continue"} - + {remoteProjectButtonLabel ?? "Continue"} ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( Enter - Select + Select ) : null} {isSubmenu ? ( Backspace - Back + Back ) : null} Esc - Close + Close
{canOpenProjectFromFileManager ? ( @@ -166,7 +166,7 @@ function RightPanelEmptyState(props: { const disabledCard = ( + ) : ( + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( - - ) : ( - - )} - -
+ + {detailsTooltip} + ); } @@ -460,144 +605,109 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { data-thread-item className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]" > -
-
-
- + - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} - - - {props.jumpLabel ? ( - props.jumpLabel - ) : topStatus ? ( - +
+
+ + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} + + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} - {props.settlementSupported ? ( - +
+
{title}
+
+ {thread.branch ? ( + {thread.branch} + ) : ( + + )} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + ) : null} - -
-
{title}
-
- {thread.branch ? ( - - {thread.branch} - - ) : ( - - )} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {driverKind ? ( - - } + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + - - {thread.modelSelection.model} - - ) : null} - {isRemote ? ( - - - } - > - - - - Running on {props.environmentLabel ?? "a remote environment"} - - - ) : null} - -
- {status === "failed" && thread.session?.lastError ? ( -
- {thread.session.lastError} + + ) : null} +
- ) : null} -
-
+
+ + {detailsTooltip} + ); }); @@ -711,9 +821,8 @@ export default function SidebarV2() { [], ); - // Project scope: chips above the list. Scoping filters the list AND - // becomes the new-thread target — one visible control doing both jobs the - // old per-project headers did. + // Project scope: one menu above the list. Scoping filters the list without + // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); const scopedProject = useMemo( () => @@ -794,7 +903,7 @@ export default function SidebarV2() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = `${projectScopeKey ?? "all"}`; + const settledResetKey = projectScopeKey ?? "all"; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -1322,243 +1431,245 @@ export default function SidebarV2() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); - const projectScrollerRef = useRef(null); - const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); - const updateProjectScrollFade = useCallback(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - setCanScrollProjectsRight( - scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, - ); - }, []); - useEffect(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - - updateProjectScrollFade(); - const resizeObserver = new ResizeObserver(updateProjectScrollFade); - resizeObserver.observe(scroller); - return () => resizeObserver.disconnect(); - }, [projects, updateProjectScrollFade]); - return ( <> - - +
+
} > - - Search + +
Search
{commandPaletteShortcutLabel ? ( - + {commandPaletteShortcutLabel} ) : null}
- - - - - - - +
+
+ + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
+
{projects.length > 0 ? ( - -
-
- {projects.length > 1 ? ( - - ) : null} - {projects.map((project) => { - const scopeKey = `${project.environmentId}:${project.id}`; - const isScoped = projectScopeKey === scopeKey; - return ( - - ); - })} -
-
- - + +
+ + + {scopedProject ? ( + + ) : ( + + )} + + {scopedProject?.title ?? "All projects"} + + + + + + setProjectScopeKey(value === "all" ? null : (value as string)) } > - - - Add project - -
+ + + All projects + + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + return ( + + + {project.title} + + ); + })} + + + + + + } + > + + + New project +
) : null} -
    - {orderedThreads.flatMap((thread, threadIndex) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); - // Settled is the ONLY thing that collapses a row: every - // not-settled thread is a full card. Density comes from users - // (or the auto rules) actually settling work, not from the - // sidebar second-guessing what still matters. - const isCard = !isSettledRow; - const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; - const previousWasCard = - previousThread != null && - !settledThreadKeys.has( - scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + +
      + {orderedThreads.flatMap((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey( + scopeThreadRef(previousThread.environmentId, previousThread.id), + ), + ); + const showSettledGap = !isCard && previousWasCard; + const row = ( + ); - const showSettledGap = !isCard && previousWasCard; - const row = ( - - ); - if (!showSettledGap) return [row]; - // The divider is its own keyed list item (not part of the first - // settled row): it keeps one stable DOM node at the boundary, - // so settling a thread slides it instead of teleporting it - // along with whichever row happens to be first in the tail — - // and row heights stay independent of neighbor classification. - return [ -
    • -
      - - Settled + if (!showSettledGap) return [row]; + // The divider is its own keyed list item (not part of the first + // settled row): it keeps one stable DOM node at the boundary, + // so settling a thread slides it instead of teleporting it + // along with whichever row happens to be first in the tail — + // and row heights stay independent of neighbor classification. + return [ +
    • +
      + Settled + +
      +
    • , + row, + ]; + })} + {hiddenSettledCount > 0 ? ( +
    • +
- , - row, - ]; - })} - {hiddenSettledCount > 0 ? ( -
  • - -
  • - ) : null} - + + + ) : null} + + {orderedThreads.length === 0 ? (
    {projects.length === 0 ? ( @@ -1567,7 +1678,7 @@ export default function SidebarV2() { + + + ), + }, + ]; + }, [ + activeThread?.id, + branchRepairAction, + handleSwitchCheckoutToThread, + handleUpdateThreadToCheckout, + localCheckoutBranchMismatch, + systemComposerBannerItems, + ]); useEffect(() => { setPendingServerThreadEnvMode(null); @@ -4313,6 +4493,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode, }); @@ -4694,6 +4877,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode: nextInteractionMode, }); @@ -4770,6 +4956,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f71e855a18f..d4cf4002a2c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1117,12 +1117,12 @@ export default function GitActionsControl({ activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase) { + if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, + threadBranch: activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { @@ -1131,7 +1131,7 @@ export default function GitActionsControl({ persistThreadBranchSync(branchUpdate.branch); }, [ - activeServerThread?.branch, + activeServerThread, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fabedfc80c7..b9bdc1c043b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { import { ChangeRequestStatusIcon, prStatusIndicator, + PrStatusTooltipContent, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, @@ -460,7 +461,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr lastVisitedAt, }, }); - const pr = resolveThreadPr(thread.branch, gitStatus.data); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; @@ -700,7 +705,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } /> - {prStatus.tooltip} + + + )} {threadStatus && } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 9bc87eed64a..2b81c15c7ad 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -83,6 +83,7 @@ import { resolveSidebarV2Status, sortThreadsForSidebarV2, } from "./Sidebar.logic"; +import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; @@ -127,6 +128,7 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, status, + branchMismatch, }: { thread: SidebarThreadSummary; projectTitle: string | null; @@ -140,6 +142,10 @@ function SidebarV2ThreadTooltip({ className: string; icon: "working" | "done" | null; } | null; + branchMismatch: { + threadBranch: string; + currentBranch: string; + } | null; }) { return (
    ) : null} + {branchMismatch ? ( +
    + +
    + You're currently checked out on another branch. +
    +
    + ) : null} {driverKind ? (
    ); diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts new file mode 100644 index 00000000000..24ccc6ef33f --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -0,0 +1,73 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; + +function status(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/current", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 42, + title: "PR branch", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "open", + }, + ...overrides, + }; +} + +describe("resolveThreadPr", () => { + it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { + expect( + resolveThreadPr({ + threadBranch: "feature/other", + gitStatus: status(), + hasDedicatedWorktree: false, + }), + ).toBeNull(); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: "feature/old-name", + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: null, + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); +}); + +describe("prStatusIndicator", () => { + it("formats PR tooltips with number, uppercase status, and title", () => { + expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ + tooltip: "PR #42 - Open: PR branch", + tooltipLead: "PR #42 - Open", + tooltipTitle: "PR branch", + }); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 55f9fbfdc04..3c58d6ea989 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -22,6 +22,8 @@ export interface PrStatusIndicator { label: string; colorClass: string; tooltip: string; + tooltipLead: string; + tooltipTitle: string; url: string; } @@ -37,14 +39,26 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { + function formatPrState(state: NonNullable["state"]): string { + return state.charAt(0).toUpperCase() + state.slice(1); + } + + function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); + const tooltipLead = formatPrStatusLead(pr, presentation.shortName); + const tooltip = `${tooltipLead}: ${pr.title}`; + if (pr.state === "open") { return { label: `${presentation.shortName} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -52,7 +66,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -60,7 +76,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -71,11 +89,31 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) { return ; } -export function resolveThreadPr( - threadBranch: string | null, - gitStatus: VcsStatusResult | null, -): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { +export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { + return ( + + {status.tooltipLead} + + ); +} + +export function resolveThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + hasDedicatedWorktree: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, hasDedicatedWorktree } = input; + if (gitStatus === null) { + return null; + } + + if (hasDedicatedWorktree) { + return gitStatus.pr ?? null; + } + + if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } @@ -199,14 +237,18 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); - const pr = resolveThreadPr(thread.branch, gitStatus.data); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { @@ -233,7 +275,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar > - {prStatus.tooltip} + + + ) : null} {threadStatus ? : null} diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 16fdff64425..520f130416a 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -34,4 +34,22 @@ describe("ComposerBannerStack", () => { expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); }); + + it("applies item-specific surface and action layout classes", () => { + const markup = renderToStaticMarkup( + Repair, + }, + ]} + />, + ); + + expect(markup).toContain("branch-surface"); + expect(markup).toContain("branch-actions"); + }); }); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index d51c23a2f27..0b9acbcc997 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -30,6 +30,8 @@ export interface ComposerBannerStackItem { readonly title: ReactNode; readonly description?: ReactNode; readonly actions?: ReactNode; + readonly className?: string; + readonly actionClassName?: string; readonly dismissLabel?: string; readonly onDismiss?: () => void; } @@ -171,17 +173,18 @@ function ComposerBannerStackAlert({ const dismissOnly = item.onDismiss && !item.actions; return ( - + {item.icon} {item.title} {item.description ? {item.description} : null} {item.actions || item.onDismiss ? ( {item.actions} {item.onDismiss ? (