diff --git a/apps/mobile/src/components/AppSymbol.ios.tsx b/apps/mobile/src/components/AppSymbol.ios.tsx index 8f3f5f8abf75..e58d2af7dbb2 100644 --- a/apps/mobile/src/components/AppSymbol.ios.tsx +++ b/apps/mobile/src/components/AppSymbol.ios.tsx @@ -1,3 +1,4 @@ +import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import { SymbolView as ExpoSymbolView } from "expo-symbols"; import { withUniwind } from "uniwind"; import type { AppSymbolViewProps } from "./AppSymbol"; @@ -6,10 +7,24 @@ export type { SFSymbol } from "expo-symbols"; export type { AppSymbolName } from "./AppSymbol"; /** - * Keep the iOS implementation isolated from the Android Tabler fallback so - * Metro does not initialize the icon package when iOS renders SF Symbols. + * Use SF Symbols on iOS except for pull requests, which have no matching + * native glyph. Import only that Tabler icon to keep the bundle small. */ function AppSymbolView(props: AppSymbolViewProps) { + const name = typeof props.name === "string" ? props.name : props.name.ios; + if (name === "arrow.triangle.pull") { + return ( + + ); + } + return ; } diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 824164dc2e2b..8d2a67000050 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -1,3 +1,8 @@ +import { + deviceToolVersionLabels, + deviceToolUpdateOwnership, + deviceToolUpdatePolicy, +} from "@t3tools/client-runtime/state/device"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -73,6 +78,7 @@ function DevicePreviewScreen({ const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); const [shuttingDown, setShuttingDown] = useState(false); + const retryHost = useAtomCommand(deviceEnvironment.list); const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); const streamRef = useRef(null); const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); @@ -116,10 +122,58 @@ function DevicePreviewScreen({ }; const controls: ScreenHeaderMenuItem[] = [ + ...(state.data?.hosts + .filter( + (host) => + state.data?.supportsHostRetry && state.data.hostStatuses[host.id]?.status === "failed", + ) + .map((host) => ({ + id: `retry-${host.id}`, + title: `Retry ${host.label}`, + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { retryHostId: host.id } }); + }, + })) ?? []), + ...(state.data?.supportsToolInspection + ? [ + { + id: "check-device-tools", + title: "Check device tool versions", + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { inspectOnly: true } }); + }, + }, + ] + : []), + { + id: "device-tools", + title: "Device tool versions", + icon: "info.circle", + onPress: () => + Alert.alert( + "Device tool versions", + deviceToolUpdateOwnership + + "\n\n" + + deviceToolUpdatePolicy( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ) + + "\n\n" + + deviceToolVersionLabels( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ).join("\n") + + "\n" + + (state.data?.hosts.find((host) => host.id === preview?.session.hostId) + ?.toolInspectionError ?? + state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? + ""), + ), + }, { id: "reload", title: "Reload stream", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !preview || shuttingDown, onPress: () => { setInputConnected(false); @@ -149,7 +203,7 @@ function DevicePreviewScreen({ { id: "rotate", title: "Rotate device", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !inputConnected, onPress: () => streamRef.current?.rotate(), }, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5c74e52cfac1..4a912f171a55 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -873,12 +873,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { useEffect(() => { if ( + !selectedProjectDraftKey || !defaultWorkspaceModeSettled || workspaceMode !== "worktree" || selectedBranchName !== null ) { return; } + // The draft screen writes a thread's branch and worktree into the draft in + // the same commit this effect runs, so the rendered selection above can be + // stale. Re-read the draft before replacing it. + const live = getComposerDraftSnapshot(selectedProjectDraftKey).workspaceSelection; + if (live && (live.mode !== "worktree" || live.branch !== null)) { + return; + } // The default may only exist as origin/ (isRemote), which // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = @@ -894,6 +902,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { defaultWorkspaceModeSettled, selectBranch, selectedBranchName, + selectedProjectDraftKey, workspaceMode, ]); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..2c747fc71310 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,7 +8,11 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; +import { + RPC_REQUIRED_SCOPES, + requiredScopeForRpcMethod, + requiredScopeForDeviceList, +} from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { it("declares exactly one scope for every RPC in the server group", () => { @@ -71,3 +75,17 @@ describe("RPC authorization scopes", () => { } }); }); + +it("requires operate permission for host retry while preserving read-only listing", () => { + expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe( + AuthOrchestrationOperateScope, + ); +}); + +it("requires operate permission for tool updates even alongside a read-only check", () => { + expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 20f2893b8335..68a167083762 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,4 +1,5 @@ import { + type DeviceListInput, AuthAccessReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -180,3 +181,9 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope } return requiredScope; } + +/** Retrying can install or restart tools even though ordinary listing is readable. */ +export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => + input.retryHostId || input.updateTool + ? AuthOrchestrationOperateScope + : AuthOrchestrationReadScope; diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index a947f8dc9e68..14537985b9b8 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service< { readonly id: DeviceHostId; readonly summary: Effect.Effect; + readonly inspect?: Effect.Effect; readonly platformAvailability: ( platform: DevicePlatform, ) => Effect.Effect; @@ -88,11 +89,11 @@ export class DeviceHost extends Context.Service< * concurrent callers share one start, and a ready host returns immediately. */ readonly ensureReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect; /** Installs and starts agent-device after the user grants agent access. */ readonly ensureAgentReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect< DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 183f24d95c5b..7a81d757c0ac 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, DeviceId, + DeviceOperationError, LOCAL_DEVICE_HOST_ID, ThreadId, type DeviceServiceState, @@ -66,7 +67,9 @@ const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string, failListAfterShutdown = false, - runtimeFailure?: NodeRuntimeUnavailableError, + runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, + inspectError = false, + installTool?: Parameters[3], ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -82,6 +85,17 @@ const fixture = Effect.fn("fixture")(function* ( run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), }; const host: DeviceHost.DeviceHost["Service"] = { + ...(inspectError + ? { + inspect: Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: new Error("offline"), + }), + ), + } + : {}), id: LOCAL_DEVICE_HOST_ID, summary: Effect.succeed({ id: LOCAL_DEVICE_HOST_ID, @@ -96,7 +110,7 @@ const fixture = Effect.fn("fixture")(function* ( Effect.gen(function* () { if (runtimeFailure) return yield* runtimeFailure; starts.push("start"); - yield* onPhase("starting"); + yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; }), ensureAgentReady: (onPhase) => @@ -117,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + const service = yield* makeWithHosts( + new Map([[host.id, host]]), + undefined, + undefined, + installTool, + ).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( ServerSettingsService, @@ -571,3 +590,144 @@ it.effect.each([ Effect.scoped, ), ); + +it.effect("retry keeps device and agent consent unchanged", () => + Effect.gen(function* () { + const { service, starts, agentStarts } = yield* fixture(); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + yield* service.configure({ enabled: true }); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts).toEqual([]); + yield* service.configure({ agentAccessEnabled: true }); + const before = agentStarts.length; + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts.length).toBe(before + 1); + }).pipe(Effect.scoped), +); + +it.effect("publishes update detail for the correct host", () => + Effect.gen(function* () { + const { service } = yield* fixture(); + const changes = yield* service.subscribe; + yield* service.configure({ enabled: true }); + const states = yield* PubSub.takeAll(changes); + expect( + states.some( + (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…", + ), + ).toBe(true); + }).pipe(Effect.scoped), +); + +it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () => + Effect.gen(function* () { + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: "private diagnostics", + }), + ); + yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); + const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(state.supportsHostRetry).toBe(true); + expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "failed", + detail: "Could not connect to this host over SSH.", + }); + }).pipe(Effect.scoped), +); + +it.effect("version discovery does not grant consent or start device tools", () => + Effect.gen(function* () { + const { service, starts, agentStarts, requests } = yield* fixture(); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts).toHaveLength(1); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => + Effect.gen(function* () { + const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts[0]?.hubInstalled).toBe(true); + expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); + expect(starts).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect( + "manual updates install only the selected tool without enabling access or starting helpers", + () => + Effect.gen(function* () { + const installed: string[] = []; + const { service, starts, agentStarts, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + (tool) => + Effect.sync(() => { + installed.push(tool); + }), + ); + const before = yield* service.state; + const state = yield* service.updateTool("agent"); + expect(installed).toEqual(["agent"]); + expect(state.supportsToolUpdate).toBe(true); + expect(state.hostStatus).toBe(before.hostStatus); + expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); + expect(state.revision).toBeGreaterThan(before.revision); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + yield* service.updateTool("hub"); + expect(installed).toEqual(["agent", "hub"]); + }).pipe(Effect.scoped), +); + +it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => + Effect.gen(function* () { + let attempts = 0; + const { service, starts, agentStarts } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + () => + Effect.suspend(() => + ++attempts === 1 + ? Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause: new Error("offline"), + }), + ) + : Effect.void, + ), + ); + const before = yield* service.state; + const result = yield* service.updateTool("agent").pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(yield* service.state).toEqual(before); + yield* service.updateTool("agent"); + expect(attempts).toBe(2); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 45a2f928ecaa..2435fbca34ab 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -38,7 +38,7 @@ import { import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; -import { ensureAgentDevice } from "./DeviceToolchain.ts"; +import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -126,6 +126,9 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly updateTool: (tool: "hub" | "agent") => Effect.Effect; + readonly inspect: Effect.Effect; + readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; readonly close: (input: DeviceCloseInput) => Effect.Effect; readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect; @@ -180,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent configuration is unavailable in this device service.", }), ), + installTool?: (tool: "hub" | "agent") => Effect.Effect, ) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); @@ -202,6 +206,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* let publishedHosts = new Map(hosts); const stateRef = yield* SynchronizedRef.make({ state: { + supportsHostRetry: true, + supportsToolUpdate: installTool !== undefined, + supportsToolInspection: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -257,7 +264,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); } const ready = yield* host - .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) + .ensureReady((status, detail) => + setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -269,7 +278,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") - : `Device host ${error.hostId} failed while ${error.step}.`, + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -308,7 +319,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; const ready = yield* host - .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) + .ensureAgentReady((phase, detail) => + setHostStatus(host.id, { status: phase, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -321,8 +334,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") : error._tag === "DeviceHostTimeoutError" - ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.` - : `Device host ${error.hostId} failed while ${error.step}.`, + ? `Agent tools did not start within ${error.timeoutMs} ms.` + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -444,7 +459,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (ready) yield* refresh(ready); }).pipe( Effect.catch((error) => - setHostStatus(host.id, { status: "failed", detail: error.message }), + setHostStatus(host.id, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), ), ), { concurrency: 4 }, @@ -452,6 +470,58 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return (yield* SynchronizedRef.get(stateRef)).state; }).pipe(Effect.withSpan("DeviceService.list")); + const inspect = Effect.gen(function* () { + yield* Effect.forEach( + hosts.values(), + (host) => + Effect.gen(function* () { + const result = yield* (host.inspect ?? host.summary).pipe(Effect.result); + if (hosts.get(host.id) !== host) return; + if (result._tag === "Failure") { + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => + value.id === host.id + ? { + ...value, + toolInspectionError: + "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.", + } + : value, + ), + })); + return; + } + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)), + })); + }), + { concurrency: 4 }, + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }); + + const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( + function* (hostId) { + yield* resolveHost(hostId); + if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + yield* Effect.gen(function* () { + const ready = + (yield* agentReadinessIfSupported(hostId)) ?? (yield* readinessIfSupported(hostId)); + if (ready) yield* refresh(ready); + }).pipe( + Effect.catch((error) => + setHostStatus(hostId, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), + ), + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }, + ); + const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( function* (input) { const currentSettings = yield* readDeviceSettings; @@ -832,6 +902,23 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + updateTool: (tool) => + lifecycleLock.withPermit( + Effect.gen(function* () { + if (!installTool) + return yield* Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }), + ); + yield* installTool(tool); + return yield* inspect; + }), + ), + retryHost, + inspect, agentCli: Effect.fail( new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, @@ -952,6 +1039,20 @@ export const make = Effect.gen(function* () { ), ), configureAgent, + (tool) => + (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause, + }), + ), + ), ); const hostContext = yield* Effect.context>>(); @@ -1042,7 +1143,7 @@ export const make = Effect.gen(function* () { return { ...service, agentCli: resolveNodeExecutable("Device automation").pipe( - Effect.flatMap(() => ensureAgentDevice(config.baseDir)), + Effect.andThen(ensureAgentDevice(config.baseDir)), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts index 9f70ca91a8e1..d7812a92b48e 100644 --- a/apps/server/src/device/DeviceToolchain.test.ts +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -1,3 +1,4 @@ +import * as PlatformError from "effect/PlatformError"; import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -6,7 +7,12 @@ import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; -import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts"; +import { + deviceToolVersions, + DEVICE_HUB_VERSION, + ensureDeviceHub, + isDeviceHubInstalled, +} from "./DeviceToolchain.ts"; it.effect("failed installation cleans staging and exposes only a safe failure message", () => Effect.gen(function* () { @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("inventory reports only completed versions without installing the required version", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped(); + for (const [version, sentinel] of [ + ["0.9.0", "0.9.0"], + [DEVICE_HUB_VERSION, "wrong"], + [".staging-123", ".staging-123"], + ]) { + const dir = path.join(base, "tools", "expo-device-hub", version!); + yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"), + "", + ); + yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!); + } + const tools = yield* deviceToolVersions(base); + expect(tools?.hub).toEqual({ + requiredVersion: DEVICE_HUB_VERSION, + installedVersions: ["0.9.0"], + runningVersion: null, + }); + expect(tools?.agent.installedVersions).toEqual([]); + expect(yield* isDeviceHubInstalled(base)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("unreadable inventory stays unknown instead of reporting no installs", () => + Effect.gen(function* () { + const tools = yield* deviceToolVersions("/unreadable"); + expect(tools).toBeUndefined(); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + description: "denied", + }), + ), + }), + ), + Effect.provide(NodeServices.layer), + ), +); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 0493902302b3..81cdb79217fe 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -1,3 +1,4 @@ +import type { DeviceToolVersions } from "@t3tools/contracts"; /** * Pinned installs of the two external tools device support is built on. * @@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); + +/** Read completed installs without downloading or starting either tool. */ +export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( + baseDir: string, + running: { hub?: string; agent?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) { + const directory = path.join(baseDir, "tools", spec.name); + const names = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + const versions = yield* Effect.filter(names, (version) => + /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version) + ? Effect.gen(function* () { + const paths = toolPaths(path, baseDir, { ...spec, version }); + const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ); + return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath)); + }) + : Effect.succeed(false), + ); + return { + requiredVersion: spec.version, + installedVersions: versions.sort(), + runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, + }; + }); + return yield* Effect.gen(function* () { + return { + hub: yield* inspect(HUB_SPEC), + agent: yield* inspect(AGENT_DEVICE_SPEC), + } satisfies DeviceToolVersions; + }).pipe(Effect.orElseSucceed(() => undefined)); +}); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b7341e04f8ad..e704d28bbc34 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -1,3 +1,5 @@ +import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts"; +import { deviceToolInstallMessage } from "@t3tools/contracts"; /** * The device host that is this machine. * @@ -31,6 +33,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -50,6 +53,8 @@ import { ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, + deviceToolVersions, + DEVICE_HUB_VERSION, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -76,6 +81,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int), + version: Schema.optional(Schema.String), }); const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); @@ -232,7 +238,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(Path.Path, path), ), ]); + const running = yield* Ref.get(runningRef); + const daemon = running?.agentDevice + ? yield* readDaemonFile().pipe(Effect.option) + : Option.none(); + const hubAlive = running + ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)) + : false; + const agentAlive = + Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false; + const tools = yield* deviceToolVersions(config.baseDir, { + ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}), + ...(agentAlive && Option.isSome(daemon) && daemon.value.version + ? { agent: daemon.value.version } + : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); return { + tools, id: hostId, kind: "local", label: "This machine", @@ -533,7 +558,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); if (running) { @@ -550,7 +575,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub)); + } const hubTool = yield* ensureDeviceHub(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -566,6 +594,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); yield* onPhase("starting"); const hub = yield* spawnHub(hubTool, nodePath); + yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const candidate = helperPaths(hubTool); const [axExists, cliExists] = yield* Effect.all([ fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), @@ -605,7 +638,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase( + "installing", + deviceToolInstallMessage("agent tools", inventory.tools?.agent), + ); + } const agentTool = yield* ensureAgentDevice(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -622,6 +661,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); + yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const next = { ...running, agentDevice }; yield* Ref.set(runningRef, next); return { ...toReady(next), agentDevice }; diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index d968d3383fae..71a15190ddc8 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; + const owners: string[] = []; let forwards = 0; let failForward = true; let rejectConfig = true; @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ ); const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; modes.push(mode); + owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); output = JSON.stringify({ nodePath: "/node", platforms: [{ platform: "ios", available: true }], @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ ), ); yield* host.ensureReady(() => Effect.void); + yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe( + Effect.provide(ServerConfig.layerTest(home, home)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + expect(new Set(owners).size).toBe(1); + expect(owners[0]).toMatch(/^[a-f0-9]{24}$/); expect(forwards).toBe(1); expect(modes.filter((mode) => mode === "start")).toHaveLength(2); yield* host.platformAvailability("ios"); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 4ccda0fdefe9..4ed6d740215c 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -2,6 +2,8 @@ import * as NodeCrypto from "node:crypto"; import { type DeviceHostSummary, DevicePlatformAvailability, + DeviceToolVersions, + deviceToolInstallMessage, type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; @@ -24,6 +26,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s const Probe = Schema.Struct({ nodePath: Schema.String, + tools: Schema.optional(DeviceToolVersions), platforms: Schema.Array(DevicePlatformAvailability), }); const Started = Schema.Struct({ @@ -70,8 +73,23 @@ const bootstrap = ( ), ); -export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { - const result = yield* bootstrap(config, "probe", "probe"); +const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) { + const fs = yield* FileSystem.FileSystem; + const server = yield* ServerConfig.ServerConfig; + const environmentId = yield* fs + .readFileString(server.environmentIdPath) + .pipe(Effect.orElseSucceed(() => server.stateDir)); + return NodeCrypto.createHash("sha256") + .update(`${environmentId}\0${server.stateDir}\0${hostId}`) + .digest("hex") + .slice(0, 24); +}); + +export const probe = Effect.fn("SshDeviceHost.probe")(function* ( + config: SshDeviceHostConfig, + owner?: string, +) { + const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe"); const value = yield* decodeProbe(result.stdout.trim()).pipe( Effect.mapError( (cause) => @@ -82,8 +100,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi id: config.id, label: config.label, kind: "ssh", - hubInstalled: false, - agentDeviceInstalled: false, + tools: value.tools, + hubInstalled: + value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false, + agentDeviceInstalled: + value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false, platforms: value.platforms, } satisfies DeviceHostSummary; }); @@ -106,23 +127,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const parentScope = yield* Scope.Scope; const ssh = yield* resolveSshCommand; - const environmentId = yield* fs - .readFileString(server.environmentIdPath) - .pipe(Effect.orElseSucceed(() => server.stateDir)); - const owner = NodeCrypto.createHash("sha256") - .update(`${environmentId}\0${server.stateDir}\0${config.id}`) - .digest("hex") - .slice(0, 24); + const owner = yield* ownerFor(config.id); const provide = ( effect: Effect.Effect< A, E, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | ServerConfig.ServerConfig >, ) => effect.pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig.ServerConfig, server), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const lock = yield* Semaphore.make(1); @@ -183,6 +202,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( summary = { ...summary, platforms: remote.platforms, + tools: remote.tools, hubInstalled: true, agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, }; @@ -351,8 +371,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( Effect.gen(function* () { stopped = false; if (ready) return ready; - summary = yield* provide(probe(config)); - yield* onPhase("installing"); + summary = yield* provide(probe(config, owner)); + yield* onPhase( + summary.hubInstalled ? "starting" : "installing", + summary.hubInstalled + ? undefined + : deviceToolInstallMessage("device hub", summary.tools?.hub), + ); return yield* connect().pipe( Effect.tapError(() => connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, @@ -399,10 +424,25 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( return { id: config.id, summary: Effect.sync(() => summary), + inspect: provide(probe(config, owner)).pipe( + Effect.tap((value) => + Effect.sync(() => { + summary = value; + }), + ), + ), current: Effect.sync(() => ready), ensureReady, ensureAgentReady: (onPhase) => - onPhase("installing").pipe( + ensureReady(onPhase).pipe( + Effect.flatMap(() => + onPhase( + summary.agentDeviceInstalled ? "starting" : "installing", + summary.agentDeviceInstalled + ? undefined + : deviceToolInstallMessage("agent tools", summary.tools?.agent), + ), + ), Effect.flatMap(() => changeAgent(true)), Effect.flatMap((value) => value?.agentDevice @@ -419,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), stop, platformAvailability: (platform) => - provide(probe(config)).pipe( + provide(probe(config, owner)).pipe( Effect.map((value) => { summary = { ...summary, platforms: value.platforms }; return value.platforms.find((p) => p.platform === platform)!; diff --git a/apps/server/src/device/deviceToolMaintenance.test.ts b/apps/server/src/device/deviceToolMaintenance.test.ts new file mode 100644 index 000000000000..4c46be11bba4 --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.test.ts @@ -0,0 +1,159 @@ +import * as Effect from "effect/Effect"; +import * as NodePathLayer from "@effect/platform-node/NodePath"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +// @effect-diagnostics nodeBuiltinImport:off - tests the same standalone script used by local and SSH hosts. +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; +import { pruneLocalDeviceTools, deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { + it("keeps current, previous, active and incomplete installs, pruning unused completed versions", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + const name = "expo-device-hub"; + const directory = (version: string) => + flat ? NodePath.join(root, `${name}@${version}`) : NodePath.join(root, name, version); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0"]) { + await NodeFSP.mkdir(directory(version), { recursive: true }); + if (version === "0.5.0") continue; + const sentinel = NodePath.join(directory(version), ".install-complete"); + await NodeFSP.writeFile(sentinel, version); + await NodeFSP.utimes( + sentinel, + Number(version.split(".")[1]), + Number(version.split(".")[1]), + ); + } + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + await pruneTools(root, [['${name}', '0.6.0']], ${flat}); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await exec(process.execPath, [ + "-e", + script, + NodePath.join(directory("0.2.0"), "active-helper.cjs"), + ]); + await expect(NodeFSP.stat(directory("0.1.0"))).rejects.toThrow(); + await expect(NodeFSP.stat(directory("0.3.0"))).rejects.toThrow(); + for (const version of ["0.2.0", "0.4.0", "0.5.0", "0.6.0"]) + expect((await NodeFSP.stat(directory(version))).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("keeps every install when the process scan fails", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-scan-")); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0"]) { + const dir = flat + ? NodePath.join(root, `expo-device-hub@${version}`) + : NodePath.join(root, "expo-device-hub", version); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), version); + } + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + ` + require('node:child_process').spawnSync = () => ({ status: 1, stdout: '' }); + pruneTools(${JSON.stringify(root)}, [['expo-device-hub', '0.3.0']], ${flat}).catch(() => process.exitCode = 1); + `, + ]); + const parent = flat ? root : NodePath.join(root, "expo-device-hub"); + expect((await NodeFSP.readdir(parent)).length).toBe(3); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("does not prune before the required version has completed installation", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + try { + const dir = flat + ? NodePath.join(root, "expo-device-hub@0.1.0") + : NodePath.join(root, "expo-device-hub/0.1.0"); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), "0.1.0"); + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + `pruneTools(${JSON.stringify(root)}, [['expo-device-hub','0.6.0']], ${flat}).catch(() => process.exitCode = 1);`, + ]); + expect((await NodeFSP.stat(dir)).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); + +it.effect("maintenance failures retain safe context and the original process result", () => + Effect.gen(function* () { + const output = { + code: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "private child diagnostics", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + for (const [operation, run] of [["prune", pruneLocalDeviceTools]] as const) { + const error = yield* run("/tools", process.execPath, "hub").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run: () => Effect.succeed(output) }), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "DeviceToolMaintenanceError", + operation, + tool: "hub", + exitCode: 1, + cause: output, + }); + expect(error.message).toBe(`Device tool ${operation} failed for hub (exit code 1).`); + expect(error.message).not.toContain(output.stderr); + } + }).pipe(Effect.provide(NodePathLayer.layer)), +); + +it("serializes competing maintenance processes after reclaiming a stale lock", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-contention-")); + try { + const lock = NodePath.join(root, ".maintenance-lock"); + await NodeFSP.mkdir(lock); + await NodeFSP.writeFile( + NodePath.join(lock, "stale-owner.json"), + JSON.stringify({ pid: 2147483647, identity: "dead" }), + ); + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + const marker = maintenancePath.join(root, 'critical-section'); + for (let attempt = 0; attempt < 8; attempt++) await withToolMaintenance(root, () => { + maintenanceFs.writeFileSync(marker, String(process.pid), { flag: 'wx' }); + for (let check = 0; check < 100; check++) { + if (maintenanceFs.readFileSync(marker, 'utf8') !== String(process.pid)) throw Error('Overlapping maintenance'); + } + maintenanceFs.unlinkSync(marker); + }); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await Promise.all(Array.from({ length: 6 }, () => exec(process.execPath, ["-e", script]))); + await expect(NodeFSP.stat(lock)).rejects.toThrow(); + expect(await NodeFSP.readdir(root)).toEqual([]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/device/deviceToolMaintenance.ts b/apps/server/src/device/deviceToolMaintenance.ts new file mode 100644 index 000000000000..43cc64e8faca --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics preferSchemaOverJson:off - JSON string literals safely embed paths and arguments in generated JavaScript. +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as ProcessRunner from "../processRunner.ts"; +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +/** Shared with the SSH bootstrap. Cleanup runs only after successful startup. */ +export const deviceToolMaintenanceScript = String.raw` +const maintenanceFs = require('node:fs'); +const maintenancePath = require('node:path'); +const maintenanceAlive = pid => { + try { process.kill(pid, 0); return true; } + catch (error) { return error.code !== 'ESRCH'; } +}; +async function withToolMaintenance(root, operation) { + maintenanceFs.mkdirSync(root, { recursive: true }); + const lock = maintenancePath.join(root, '.maintenance-lock'); + const nonce = require('node:crypto').randomUUID(); + const ownerFile = process.pid + '.' + nonce + '.json'; + const candidate = lock + '.' + nonce; + const holder = { pid: process.pid }; + const deadline = Date.now() + 30000; + const removeEmptyLock = () => { + try { maintenanceFs.rmdirSync(lock); } + catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code)) throw error; } + }; + maintenanceFs.mkdirSync(candidate); + try { + maintenanceFs.writeFileSync(maintenancePath.join(candidate, ownerFile), JSON.stringify(holder)); + while (true) { + try { + // Publish a populated directory atomically; rename cannot replace another populated lock. + maintenanceFs.renameSync(candidate, lock); + break; + } + catch (error) { + if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error; + let files = []; + try { files = maintenanceFs.readdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } + if (files.length === 1) { + const previousFile = maintenancePath.join(lock, files[0]); + let previous; + try { previous = JSON.parse(maintenanceFs.readFileSync(previousFile, 'utf8')); } catch {} + if (Number.isSafeInteger(previous?.pid) && previous.pid > 0 && !maintenanceAlive(previous.pid)) { + // The unique filename belongs only to that owner. Never unlink a replacement owner's file. + try { maintenanceFs.unlinkSync(previousFile); } catch (error) { if (error.code !== 'ENOENT') throw error; } + } + } + // A concurrent acquirer publishes its owner file with the directory, so this cannot remove it. + removeEmptyLock(); + if (Date.now() >= deadline) throw Error('Device tool maintenance is locked. Retry when the other operation finishes.'); + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + try { return operation(); } + finally { + maintenanceFs.unlinkSync(maintenancePath.join(lock, ownerFile)); + removeEmptyLock(); + } + } finally { + maintenanceFs.rmSync(candidate, { recursive: true, force: true }); + } +} +function pruneTools(root, specs, flat) { + return withToolMaintenance(root, () => { + // Keep installs used by any running helper, including older T3 releases. + const scan = process.platform === 'win32' + ? require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'], { encoding: 'utf8', timeout: 10000 }) + : require('node:child_process').spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8', timeout: 10000 }); + if (scan.status !== 0 || !scan.stdout) return; + for (const [name, required] of specs) { + const parent = flat ? root : maintenancePath.join(root, name); + let names; + try { names = maintenanceFs.readdirSync(parent); } catch { continue; } + const completed = []; + for (const item of names) { + const version = flat ? (item.startsWith(name + '@') ? item.slice(name.length + 1) : '') : item; + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) continue; + const directory = maintenancePath.join(parent, item); + try { + if (!maintenanceFs.lstatSync(directory).isDirectory()) continue; + if (maintenanceFs.readFileSync(maintenancePath.join(directory, '.install-complete'), 'utf8').trim() !== version) continue; + completed.push({ version, directory, modified: maintenanceFs.statSync(maintenancePath.join(directory, '.install-complete')).mtimeMs }); + } catch {} + } + // Never prune until the required install has completed. Retain the last other successful install. + if (!completed.some(value => value.version === required)) continue; + const previous = completed.filter(value => value.version !== required).sort((a, b) => b.modified - a.modified || b.version.localeCompare(a.version, 'en', { numeric: true }))[0]?.version; + for (const { version, directory } of completed) { + if (version === required || version === previous || scan.stdout.includes(directory + maintenancePath.sep)) continue; + maintenanceFs.rmSync(directory, { recursive: true, force: true }); + } + } + }); +} +`; + +class DeviceToolMaintenanceError extends Schema.TaggedError()( + "DeviceToolMaintenanceError", + { + operation: Schema.Literal("prune"), + tool: Schema.Literals(["hub", "agent"]), + exitCode: Schema.NullOr(Schema.Int), + cause: Schema.Defect(), + }, +) { + override get message() { + return `Device tool ${this.operation} failed for ${this.tool} (exit code ${this.exitCode ?? "unknown"}).`; + } +} + +const runMaintenance = Effect.fn("DeviceToolchain.maintenance")(function* ( + nodePath: string, + script: string, + operation: "prune", + tool: "hub" | "agent", +) { + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner.run({ + command: nodePath, + args: [ + "-e", + deviceToolMaintenanceScript + + "\n" + + script + + ".catch(error => { console.error(error.message); process.exitCode = 1; });", + ], + }); + if (result.code !== 0) + return yield* Effect.fail( + new DeviceToolMaintenanceError({ operation, tool, exitCode: result.code, cause: result }), + ); +}); + +export const pruneLocalDeviceTools = Effect.fn("DeviceToolchain.prune")(function* ( + baseDir: string, + nodePath: string, + tool: "hub" | "agent", +) { + const path = yield* Path.Path; + yield* runMaintenance( + nodePath, + `pruneTools(${JSON.stringify(path.join(baseDir, "tools"))}, ${JSON.stringify(tool === "hub" ? [["expo-device-hub", DEVICE_HUB_VERSION]] : [["agent-device", AGENT_DEVICE_VERSION]])}, false)`, + "prune", + tool, + ); +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index eadd85fd047d..8d9bc77d891c 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -83,15 +83,16 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir const file=path.join(state,'daemon.json'); if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } -else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } +else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } `, ); + await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs")); const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; let invocation = 0; const invoke = async ( owner: string, - mode: "start" | "agent-start" | "stop-agent" | "stop", + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", upgraded = false, ) => { const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); @@ -107,6 +108,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr }); return result.stdout ? JSON.parse(result.stdout) : null; }; + const inventory = await invoke("one", "probe"); + expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); + expect(inventory.tools.hub.runningVersion).toBeNull(); + expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); + await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow(); const template = NodePath.join(home, "hub-template"); await NodeFSP.cp(hubDir, template, { recursive: true }); await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); @@ -114,11 +120,14 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.symlink("2147483647:exited-installer", installLock); await NodeFSP.writeFile( NodePath.join(bin, "npm"), - `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, + `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, { mode: 0o755 }, ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); + // Unavailable advisory bookkeeping must not prevent either helper from starting. + await NodeFSP.writeFile(NodePath.join(root, "tools/.maintenance-lock"), "blocked"); + await NodeFSP.writeFile(NodePath.join(root, "tools/.users"), "unwritable lease directory"); try { const [manual, concurrent] = await Promise.all([ invoke("one", "start"), @@ -135,6 +144,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr ]); expect(concurrentAgent.hubPort).toBe(first.hubPort); expect(concurrentAgent.daemonPort).toBe(first.daemonPort); + const running = await invoke("one", "probe"); + expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION); + expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION); const second = await invoke("two", "agent-start"); const reused = await invoke("one", "agent-start"); expect(reused.hubPort).toBe(first.hubPort); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index bbdb828c1a74..de412b5873d5 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -1,3 +1,4 @@ +import { deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; @@ -28,6 +29,7 @@ const mode = ${JSON.stringify(mode)}; const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; ` + + deviceToolMaintenanceScript + String.raw` const fs = require('node:fs'); const path = require('node:path'); @@ -39,6 +41,35 @@ const state = path.join(root, 'hosts', owner); const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; +const toolVersions = (name, requiredVersion, entry, record) => { + const directory = path.join(root, 'tools'); + const prefix = name + '@'; + let names = []; + try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; } + let unreadable = false; + const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => { + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false; + const dir = path.join(directory, prefix + version); + try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch (error) { if (error.code !== 'ENOENT') unreadable = true; return false; } + }).sort(); + if (unreadable) return null; + let runningVersion = null; + if (record?.entryPath && record?.pid) { + const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; + runningVersion = installedVersions.find(version => { + const install = path.join(directory, prefix + version); + return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep); + }) ?? null; + } + return { requiredVersion, installedVersions, runningVersion }; +}; +const versions = () => { + const result = { + hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))), + agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }), + }; + return result.hub && result.agent ? result : undefined; +}; const stopHub = hub => { if (!hub || hub.owner !== owner) return; const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; @@ -111,7 +142,7 @@ async function install(name, version, entry) { if (mode === 'probe') { if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); - console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; } fs.mkdirSync(state, { recursive: true, mode: 0o700 }); // Serialize starts and stops for this environment/host owner, including agent startup. @@ -185,7 +216,8 @@ async function install(name, version, entry) { } const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; - console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, + await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {}); + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d118bd3d012c..90271e00adfb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -146,7 +146,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; +import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -3453,10 +3453,23 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { "rpc.aggregate": "device", }), - [WS_METHODS.deviceList]: (_input) => - observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { - "rpc.aggregate": "device", - }), + [WS_METHODS.deviceList]: (input) => + observeRpcEffect( + WS_METHODS.deviceList, + input.inspectOnly && !input.updateTool + ? deviceService.inspect + : authorizeEffect( + requiredScopeForDeviceList(input), + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, + ), + { + "rpc.aggregate": "device", + }, + ), [WS_METHODS.deviceOpen]: (input) => observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { "rpc.aggregate": "device", diff --git a/apps/web/package.json b/apps/web/package.json index e6980cf778ea..ec7334e1fd86 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index cdf3aeffbea1..ffc89c33e794 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -54,6 +54,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; @@ -283,9 +284,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ) : ( )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + @@ -298,7 +297,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ - {previousWorktreeLabel} + ) : null} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index a7fa1ce99724..9838593a23f7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -73,6 +73,7 @@ import { } from "./ui/combobox"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; export interface BranchToolbarBranchSelectorHandle { open: () => void; @@ -757,7 +758,7 @@ export function BranchToolbarBranchSelector({ onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
- {itemValue} + {badge && {badge}}
@@ -818,12 +819,11 @@ export function BranchToolbarBranchSelector({ data-composer-label className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" > - - {triggerLabel} - + className="flex w-full max-w-[240px] transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" + /> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 667eb0e11f6c..c9540e4a0a84 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -229,6 +229,8 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; +import { undoLatestThreadAction } from "../hooks/showUndoToast"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); const getShortcutContext = useCallback( - () => ({ + (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), previewFocus: isPreviewFocused(), previewOpen: previewPanelOpen, + editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, isWeb: !isElectron, isDesktop: isElectron, @@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = getShortcutContext(); + const shortcutContext = getShortcutContext(event.target); if ( !shortcutContext.terminalFocus && @@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "thread.undo") { + // Only claim the chord when there is an Undo to run; otherwise the + // page keeps its native behavior for the key. + if (event.repeat) return; + if (undoLatestThreadAction()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + if (command === "thread.pin") { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index c108207304c4..0e452fd4ac26 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -46,6 +46,7 @@ import { buildDocJson, buildTiptapContent, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -136,7 +137,7 @@ export interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem?: boolean, ) => boolean; @@ -754,8 +755,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { dropcursor: false, gapcursor: false, trailingNode: false, + code: false, // Plain mode has no marks: typed markers stay literal characters. - ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }), + ...(richText ? {} : { bold: false, italic: false, strike: false }), }), ComposerMentionExtension, ComposerSkillExtension, @@ -764,6 +766,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerMarkersExtension, ...(richText ? [ + ComposerCodeExtension, TaskList, ComposerTaskItemExtension.extend({ addInputRules() { @@ -909,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ? ("ArrowDown" as const) : event.key === "ArrowUp" ? ("ArrowUp" as const) - : null; + : event.key === "Escape" + ? ("Escape" as const) + : null; if (!key) return false; const handled = handler(key, event); if (handled) { diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 8313bed9daab..9e92c4d2c76e 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -281,10 +281,16 @@ describe("sidebar list motion", () => { layout([b, fresh]); motion.update(true); expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); - expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(fresh.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(83px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); const clone = a.clones[0]!; expect(clone.style).toMatchObject({ position: "absolute", @@ -299,10 +305,16 @@ describe("sidebar list motion", () => { expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]); expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]); expect(clone.children[1]!.attributes).toEqual(icon.attributes); - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 1, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(-83px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); expect(parent.children.includes(clone)).toBe(true); motion.update(true); expect(clone.animations).toHaveLength(1); @@ -311,6 +323,106 @@ describe("sidebar list motion", () => { expect(parent.children.includes(clone)).toBe(false); }); + it("rides entering rows on the shelf displacement so an opened shelf moves as one block", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const z = new TestRow("z", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + // The shelf is anchored below the list, so two revealed rows lift the + // header and its existing rows by the same 72px. + layout([a, header, x, y, z]); + for (const row of [header, x, y, z]) row.offsetTop -= 72; + motion.update(true); + expectMove(header, 72); + expectMove(x, 72); + expect(a.animate).not.toHaveBeenCalled(); + expect(y.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expect(z.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("rides new rows on a retained header's unfinished travel", () => { + const header = new TestRow("header", 32); + const above = new TestRow("above", 39); + const incoming = new TestRow("incoming", 36); + const { motion, layout } = fixture([header]); + motion.update(true); + layout([above, header]); + motion.update(true); + header.animations[0]!.progress = 0.25; + layout([above, header, incoming]); + motion.update(true); + expect(incoming.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(-30px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("cancels in-flight row travel when a bulk update skips animation", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + const first = a.animations[0]!; + first.progress = 0.4; + const incoming = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`)); + layout(incoming); + motion.update(true); + expect(first.cancel).toHaveBeenCalledOnce(); + expect(incoming.every((row) => row.animate.mock.calls.length === 0)).toBe(true); + }); + + it("keeps in-flight shelf entry travel when the shelf closes mid-animation", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + header.animations[0]!.progress = 0.25; + x.animations[0]!.progress = 0.25; + entry.progress = 0.25; + layout([a, header, x]); + motion.update(true); + expect(entry.cancel).toHaveBeenCalledOnce(); + const clone = y.clones[0]!; + // Remaining entry travel (36 * 0.75) is baked into the clone's box so the + // fade starts from the row's current visual top instead of jumping to 0. + expect(clone.style.top).toBe("110px"); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.25, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(9px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expectMove(header, -9); + expectMove(x, -9); + }); + it("clears exit clones on pickup and does not fade the release commit", () => { const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; const { motion, layout, parent } = fixture([a, b]); @@ -329,10 +441,16 @@ describe("sidebar list motion", () => { expect(c.animations).toHaveLength(0); layout([c, a]); motion.update(true); - expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(a.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(0px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.dispose(); expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce(); }); @@ -349,15 +467,40 @@ describe("sidebar list motion", () => { motion.update(true); expect(marker.clones).toHaveLength(0); const clone = a.clones[0]!; - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.4, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(40px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.update(false); expect(parent.children).toEqual([]); expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); }); + it("retargets an in-flight entry when a later layout shift moves the row", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + entry.progress = 0.25; + for (const row of [header, x, y]) row.offsetTop += 40; + motion.update(true); + expect(entry.cancel).not.toHaveBeenCalled(); + // Remaining 27px of the 36px entry plus the new 40px shift. + expectMove(y, -13); + }); + it("skips fades when a large list change would clone too many rows", () => { const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`)); const { motion, layout, parent } = fixture(rows); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index d70c839d4941..dba6072e9d01 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,4 +1,8 @@ const motionTiming = { duration: 150, easing: "ease-out" }; +// Rows normally ride their displaced neighbour's travel. Absent a moving +// neighbour, a row still travels on its own, clamped so a tall card does not +// slide its full height. +const rowTravel = (height: number) => Math.min(height, 40); // A project filter change or a bulk snooze swaps a large part of the list at // once. Fades are the expensive part: every removed row gets a deep clone and // every clone and entering row gets its own animation, and the layout reads @@ -23,7 +27,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { "(prefers-reduced-motion: reduce)", ); const running = new Map(); - const entering = new Map(); + const entering = new Map(); const exiting = new Map(); // Visual tops at drag release, relative to the list, so the release // commit can glide every row from where dnd-kit left it into its slot. @@ -31,15 +35,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const remainingOffset = (node: HTMLElement) => { const current = running.get(node); - return current ? current.offset * (1 - progress(current.animation)) : 0; + const run = current ? current.offset * (1 - progress(current.animation)) : 0; + const entry = entering.get(node); + const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0; + return run + enter; }; const clearFades = () => { - for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel(); + for (const entry of entering.values()) entry.animation.cancel(); + for (const animation of exiting.values()) animation.cancel(); for (const node of exiting.keys()) node.remove(); entering.clear(); exiting.clear(); }; - const fadeOut = (node: HTMLElement, position: RowPosition) => { + const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => { if (position.height === 0) return; // React owns the removed row; only a noninteractive copy stays for the fade. const clone = node.cloneNode(true) as HTMLElement; @@ -72,8 +80,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }); parent.append(clone); const entry = entering.get(node); + const entryProgress = entry ? progress(entry.animation) : 1; const animation = clone.animate( - [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }], + [ + { opacity: entryProgress, transform: "translateY(0px)" }, + { opacity: 0, transform: `translateY(${travel}px)` }, + ], motionTiming, ); exiting.set(clone, animation); @@ -99,7 +111,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }; const move = (node: HTMLElement, offset: number) => { cancel(node); - if (offset === 0) return; + const entry = entering.get(node); + // The newer transform supersedes entry travel; its original opacity keeps fading. + if (entry) entry.travel = 0; + if (offset === 0 && !entry) return; const animation = node.animate( [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], motionTiming, @@ -144,15 +159,42 @@ export function createSidebarListMotion(parent: HTMLUListElement) { positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; + const movedDelta = new Map(); + const nextOrder = [...next.keys()]; + const oldOrder = positions === null ? [] : [...positions.keys()]; + const ridingDelta = ( + order: readonly HTMLElement[], + index: number, + retained: (node: HTMLElement) => boolean, + ) => { + for (let cursor = index - 1; cursor >= 0; cursor--) { + const node = order[cursor]!; + const delta = movedDelta.get(node); + if (delta !== undefined) return delta; + if (retained(node)) return remainingOffset(node); + } + return undefined; + }; if (!shouldAnimate) clearFades(); else { + // A shelf that opens above its collapsed anchor shifts every retained + // row by the same amount. Entering rows take that same displacement so + // the shelf arrives as one moving block instead of rows popping into + // their final slots; exiting rows leave by it. + for (const [node, position] of next) { + const previousTop = positions!.get(node)?.top; + if (previousTop === undefined || previousTop === position.top) continue; + movedDelta.set(node, previousTop + remainingOffset(node) - position.top); + } for (const [node, position] of positions!) { - if (!next.has(node)) fadeOut(node, position); + if (next.has(node)) continue; + const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); + fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } } - for (const [node, animation] of entering) { + for (const [node, entry] of entering) { if (!next.has(node)) { - animation.cancel(); + entry.animation.cancel(); entering.delete(node); } } @@ -160,26 +202,35 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!shouldAnimate || !next.has(node)) cancel(node); } if (shouldAnimate) { - for (const [node, position] of next) { - const previousTop = positions?.get(node)?.top; + for (const [index, node] of nextOrder.entries()) { + const position = next.get(node)!; + const previousTop = positions!.get(node)?.top; if (previousTop === undefined) { if (position.height > 0) { - const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming); - entering.set(node, animation); + const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n)); + const travel = delta === undefined ? -rowTravel(position.height) : delta; + const animation = node.animate( + [ + { opacity: 0, transform: `translateY(${travel}px)` }, + { opacity: 1, transform: "translateY(0px)" }, + ], + motionTiming, + ); + entering.set(node, { animation, travel }); animation.addEventListener( "finish", () => { - if (entering.get(node) === animation) entering.delete(node); + if (entering.get(node)?.animation === animation) entering.delete(node); }, { once: true }, ); } continue; } - if (previousTop === position.top) continue; + const delta = movedDelta.get(node); // Computed progress includes the effect's easing. Only our own // translate is carried forward; dnd-kit's transforms are never read. - move(node, previousTop + remainingOffset(node) - position.top); + if (delta !== undefined) move(node, delta); } } if (released !== null) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a03196fc3b8d..23c55fe8ecce 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -208,12 +208,7 @@ import { type TerminalStatusIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; -import { - resolveSnoozePresets, - snoozeWakeDescription, - snoozeWakeLabel, - type SnoozePreset, -} from "./Sidebar.snooze"; +import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; @@ -242,6 +237,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { composerDraftHasUserContent, DraftId, @@ -379,7 +375,7 @@ function SidebarThreadTooltip({ {thread.branch ? (
-
{thread.branch}
+
) : null} {branchMismatch ? ( @@ -1932,9 +1928,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {thread.branch ? ( <> - - {thread.branch} - + ) : ( @@ -3079,7 +3077,9 @@ export default function Sidebar() { settlingThreadKeysRef.current.add(threadKey); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); + const result = await settleThread(threadRef, { + undoToast: opts.coSettlingKeys === undefined, + }); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { @@ -3731,7 +3731,9 @@ export default function Sidebar() { // Snoozing the open thread moves you forward, same as settle — // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const result = await snoozeThread(threadRef, preset.snoozedUntil, { + undoToast: opts.coSnoozingKeys === undefined, + }); if (result._tag === "Failure") { // Never navigate away from a thread that did not snooze. return isAtomCommandInterrupted(result) @@ -3777,23 +3779,9 @@ export default function Sidebar() { ); return; } - if (outcome.status !== "success") return; - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, - }), - ); })(); }, - [attemptUnsnooze, performSnooze, timestampFormat], + [performSnooze], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 275105a79099..58c28609dcef 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -4,6 +4,8 @@ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { cn } from "~/lib/utils"; +import { MiddleTruncate } from "./ui/middle-truncate"; + /** * Flip this while reviewing command-palette thread subtitles. * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default) @@ -84,7 +86,7 @@ export function ThreadCommandSubtitle(props: { {projectLabel ? : null} - {branchLabel} + ) : null} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 5feda71ea8f3..1401965e90c7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -2,6 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import { + CircleAlertIcon, + CircleCheckIcon, + MessageCircleQuestionIcon, + ShieldQuestionIcon, +} from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; @@ -154,7 +160,28 @@ function EnvironmentNotifications({ type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", title, description: thread.title, - data: { hideCopyButton: true }, + data: { + hideCopyButton: true, + leadingIcon: + kind === "completion" ? ( + + ) : status === "approval" ? ( + + ) : status === "failed" ? ( + + ) : ( + + ), + }, actionProps: { children: "Open thread", onClick: () => { diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index f0cdc0163ce6..02155ac7c72c 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,6 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { MiddleTruncate } from "../ui/middle-truncate"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -246,9 +247,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - - {node.name} - + {node.stat && ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3fe6e740fb9f..9cbf733e1061 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -53,7 +53,6 @@ import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { - Fragment, memo, type ComponentProps, type ReactNode, @@ -124,6 +123,7 @@ import { import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerMenuState } from "./useComposerMenuState"; +import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { @@ -186,8 +186,6 @@ import { import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { - COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, - COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, shouldAnimateComposerRestingTransition, @@ -196,7 +194,6 @@ import { shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; -import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ComposerContextActionsContext, @@ -993,22 +990,32 @@ const extendReplacementRangeForTrailingSpace = ( return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; -function useRestingComposerControlsLayout(host: HTMLDivElement | null) { +function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) { + const [controls, setControls] = useState(null); const controlsRef = useRef(null); + const attachControls = useCallback((element: HTMLDivElement | null) => { + controlsRef.current = element; + setControls(element); + }, []); const hostRef = useRef(host); hostRef.current = host; - const [layout, setLayout] = useState({ hiddenCount: 0, visible: true }); + const [layout, setLayout] = useState<{ + hiddenCount: number; + iconOnlyCount?: number; + visible: boolean; + }>({ hiddenCount: 0, visible: true }); const measure = useCallback(() => { - const currentHost = hostRef.current; - const controls = controlsRef.current; - // The controls only mount while the composer rests, so the expanded - // composer pays no layout reads here despite the every-render effect. + const currentHost = useControlsAsHost ? controls : hostRef.current; if (currentHost === null || !controls) return; const measurement = measureRestingComposerControls(controls); if (!measurement) return; - const hostWidth = currentHost.clientWidth; + const style = getComputedStyle(currentHost); + const hostWidth = + currentHost.clientWidth - + (Number.parseFloat(style.paddingInlineStart) || 0) - + (Number.parseFloat(style.paddingInlineEnd) || 0); setLayout((current) => { const next = resolveRestingComposerControlsLayout({ @@ -1016,25 +1023,48 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) { hostWidth, previous: current, }); - return next.hiddenCount === current.hiddenCount && next.visible === current.visible + return next.hiddenCount === current.hiddenCount && + next.iconOnlyCount === current.iconOnlyCount && + next.visible === current.visible ? current : next; }); - }, []); + }, [controls, useControlsAsHost]); - useLayoutEffect(measure); + useLayoutEffect(measure, [measure, host]); useEffect(() => { - if (!host) return; + const currentHost = useControlsAsHost ? controls : host; + if (!currentHost || !controls) return; const observer = new ResizeObserver(measure); - observer.observe(host); + const observeControls = () => { + observer.disconnect(); + observer.observe(currentHost); + observer.observe(controls); + controls + .querySelectorAll( + "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", + ) + .forEach((element) => observer.observe(element)); + measure(); + }; + observeControls(); + const mutations = new MutationObserver(observeControls); + mutations.observe(controls, { childList: true, subtree: true, characterData: true }); document.fonts.addEventListener("loadingdone", measure); return () => { observer.disconnect(); + mutations.disconnect(); document.fonts.removeEventListener("loadingdone", measure); }; - }, [host, measure]); - - return { controlsRef, hiddenBlockCount: layout.hiddenCount, controlsVisible: layout.visible }; + }, [host, controls, useControlsAsHost, measure]); + + return { + controlsRef, + attachControls, + hiddenBlockCount: layout.hiddenCount, + iconOnlyBlockCount: layout.iconOnlyCount ?? 0, + controlsVisible: layout.visible, + }; } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { @@ -1091,7 +1121,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop opticalSize={size === "xs" ? "default" : "large"} /> )} - + {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -1122,7 +1152,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop } > - {runtimeModeOption.label} + {runtimeModeOption.label} {runtimeModeOptions.map((mode) => { @@ -2065,9 +2095,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), - ); + const { + trigger: composerTrigger, + setTrigger: setComposerTrigger, + resolveTrigger: resolveComposerTrigger, + dismissTrigger: dismissComposerTrigger, + resetTrigger: resetComposerTrigger, + } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); // Active ArrowUp recall. Cleared on edit and on thread switch. const promptHistoryPositionRef = useRef(null); @@ -2099,8 +2133,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key: 0, active: false, }); - const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = - usePanelAnimationSettings(); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt; @@ -2115,7 +2147,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); - const composerFooterControlsRef = useRef(null); const composerSurfaceRef = useRef(null); const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); @@ -2586,7 +2617,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + composerDraftTarget, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + setComposerTrigger, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -2617,9 +2654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); const { controlsRef: restingComposerControlsRef, + attachControls: attachRestingComposerControls, hiddenBlockCount: restingControlsHiddenBlockCount, + iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, } = useRestingComposerControlsLayout(restingControlsHost); + const expandedControlsLayout = useRestingComposerControlsLayout(null, true); const pendingPrimaryAction = useMemo( () => activePendingProgress @@ -3090,7 +3130,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = prompt; const { cursor, trigger } = composerStateAtPromptEnd(prompt); setComposerCursor(cursor); - setComposerTrigger(trigger); + resetComposerTrigger(trigger); } lastSyncedPendingInputRef.current = null; return; @@ -3115,7 +3155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = nextCustomAnswer; const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); setComposerCursor(cursor); - setComposerTrigger(trigger); + resetComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, @@ -3123,6 +3163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingUserInput?.requestId, prompt, promptRef, + resetComposerTrigger, ]); // ------------------------------------------------------------------ @@ -3133,10 +3174,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerSubmissionError(null); setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); - }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); + }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -3165,22 +3206,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); setIsComposerFooterCompact(initialCompactness.footerCompact); if (typeof ResizeObserver === "undefined") return; - const footerControls = composerFooterControlsRef.current; - const stopFooterControlsFade = footerControls - ? observeResponsiveBreakpointFade({ - target: footerControls, - container: composerForm, - active: panelAnimationsActive, - durationMs: panelAnimationDurationMs, - breakpoint: { - value: composerFooterHasWideActions - ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, - unit: "px", - }, - }) - : undefined; - const observer = new ResizeObserver(() => { const nextCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact((previous) => @@ -3196,7 +3221,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) observer.observe(composerForm); return () => { observer.disconnect(); - stopFooterControlsFade?.(); }; }, [ activeThreadId, @@ -3204,8 +3228,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerFooterHasWideActions, isComposerApprovalState, isComposerCollapsedMobile, - panelAnimationDurationMs, - panelAnimationsActive, ]); // ------------------------------------------------------------------ @@ -3419,6 +3441,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, + setComposerTrigger, composerDraftTarget, composerTerminalContexts, setComposerDraftTerminalContexts, @@ -3514,6 +3537,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, + setComposerTrigger, ], ); @@ -3542,9 +3566,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: resolveComposerTrigger( + detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + ), }; - }, [readComposerSnapshot]); + }, [readComposerSnapshot, resolveComposerTrigger]); const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( @@ -3897,7 +3923,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(null); setComposerHighlightedItemId(null); }, - [composerDraftTarget, promptRef, setComposerDraftPrompt], + [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger], ); const navigatePromptHistory = useCallback( @@ -3956,7 +3982,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Callbacks: command key // ------------------------------------------------------------------ const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem = false, ) => { @@ -3967,6 +3993,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; + if (key === "Escape") { + if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; + dismissComposerTrigger(trigger); + composerMenuOpenRef.current = false; + return true; + } if (menuIsActive) { const currentItems = composerMenuItemsRef.current; const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; @@ -4356,6 +4388,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, promptRef, setComposerDraftPrompt, + setComposerTrigger, takeStashEntry, importContextRecords, ], @@ -4619,6 +4652,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) clearComposerDraftPromptAndImages, clearComposerDraftTerminalContexts, setComposerDraftPrompt, + setComposerTrigger, composerDraftTarget, composerFilesRef, composerImagesRef, @@ -4902,11 +4936,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerScrollCollapsed, ]); - const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; - const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact; + const restingHiddenBlockCount = composerControlsInStrip + ? restingControlsHiddenBlockCount + : expandedControlsLayout.hiddenBlockCount; + const iconOnlyBlockCount = composerControlsInStrip + ? restingControlsIconOnlyBlockCount + : expandedControlsLayout.iconOnlyBlockCount; const restingProviderTraitsPicker = renderProviderTraitsPicker({ ...providerTraitsPickerInput, - size: "xs", + size: composerControlsInStrip ? "xs" : "sm", hidden: composerControlsHidden || restingHiddenBlockCount > 1, }); const restingBlockDefs = [ @@ -4917,7 +4955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) content: ( <> - {composerControlsInStrip ? restingProviderTraitsPicker : providerTraitsPicker} + {restingProviderTraitsPicker} ), }, @@ -5024,7 +5062,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) triggerClassName={ composerControlsInStrip ? "min-w-13 shrink text-xs! @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:w-0 @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:flex-none" - : "-ms-2.5" + : "-ms-2.5 min-w-13" } terminalOpen={terminalOpen} open={isComposerModelPickerOpen} @@ -5051,65 +5089,52 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onOpenProviderSetup={onOpenProviderSetup} /> - {composerControlsCompact ? ( - - ) : ( - <> - {restingBlockDefs.map((def, index) => { - if (!composerControlsInStrip) { - return {def.content}; - } - const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; - return ( - - ); - })} - {composerControlsInStrip ? ( + <> + {restingBlockDefs.map((def, index) => { + const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; + return (
= restingBlockDefs.length - iconOnlyBlockCount ? "true" : "false" + } + aria-hidden={hidden || undefined} + inert={hidden || undefined} className={cn( - "min-w-0 shrink-0", - hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute", + "flex w-max min-w-max shrink-0 items-center gap-1", + hidden && "pointer-events-none invisible absolute", + index >= restingBlockDefs.length - iconOnlyBlockCount && + "[&_[data-composer-control-label]]:pointer-events-none [&_[data-composer-control-label]]:invisible [&_[data-composer-control-label]]:absolute [&_[data-composer-control-label]]:w-max [&_[data-composer-control-label]]:max-w-none [&_[data-composer-control-compact-icon]]:[visibility:inherit] [&_[data-composer-control-compact-icon]]:relative", )} > -
- ) : null} - - )} + ); + })} +
+
+ ); const showTasksTab = @@ -5932,7 +5957,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); setComposerHighlightedItemId(null); setComposerCursor(cursor); - setComposerTrigger( + resetComposerTrigger( options?.detectTrigger ? detectComposerTrigger( promptForState, @@ -6041,6 +6066,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerModelPickerOpen, openModelPicker, readComposerSnapshot, + resetComposerTrigger, + setComposerTrigger, selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, @@ -6133,7 +6160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerControlsInStrip && restingControlsHost ? createPortal(
diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index da293c38ddc9..13606e75c870 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -17,7 +17,7 @@ import { } from "@t3tools/shared/model"; import { memo, useCallback } from "react"; import type { VariantProps } from "class-variance-authority"; -import { ZapIcon } from "lucide-react"; +import { GaugeIcon, ZapIcon } from "lucide-react"; import { buttonVariants } from "../ui/button"; import { Menu, @@ -32,6 +32,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ComposerControl, ComposerControlChevron, @@ -589,6 +590,7 @@ export const TraitsPicker = memo(function TraitsPicker({ primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled, }); + const accessibleLabel = showFastModeIcon ? `${triggerLabel}, Fast mode on` : triggerLabel; const fastModeIcon = showFastModeIcon ? ( <> - - } - > - {isCodexStyle ? ( - // The label truncates itself; clipping the wrapper too would cut off - // the chevron, whose negative end margin overhangs the wrapper edge. - - {fastModeIcon} - {triggerLabel} - - - ) : ( - <> - {fastModeIcon} - {triggerLabel} - - - )} - + + + } + /> + } + > + {isCodexStyle ? ( + // The label truncates itself; clipping the wrapper too would cut off + // the chevron, whose negative end margin overhangs the wrapper edge. + + {fastModeIcon ?? ( + + + + )} + + {triggerLabel} + + + + ) : ( + <> + {fastModeIcon ?? ( + + + + )} + {triggerLabel} + + + )} + + {accessibleLabel} +
Branch
-
{snapshot.branch}
+
+ +
) : null} {snapshot.baseRef ? ( <>
Base
-
{snapshot.baseRef}
+
+ +
) : null} {snapshot.worktreePath ? ( <>
Path
-
{snapshot.worktreePath}
+
+ +
) : null} {snapshot.setupScript ? ( diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts index 052c9b1d9f4d..8920bd52d2b1 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts @@ -20,7 +20,13 @@ function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth? } return null; }, - querySelectorAll: () => [{ getBoundingClientRect: () => ({ width: 140 }) }], + querySelectorAll: () => [ + { + dataset: {}, + querySelectorAll: () => [], + getBoundingClientRect: () => ({ width: 140 }), + }, + ], }; vi.stubGlobal("getComputedStyle", (element: unknown) => { if (element === label) return { flexGrow: input.flexGrow }; @@ -40,6 +46,7 @@ describe("measureRestingComposerControls", () => { expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 0, + iconOnlyCount: 0, visible: true, }); }); @@ -50,6 +57,7 @@ describe("measureRestingComposerControls", () => { expect(measurement.naturalFixedWidth).toBe(192); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 1, + iconOnlyCount: 1, visible: true, }); }); @@ -60,6 +68,7 @@ describe("measureRestingComposerControls", () => { expect(measurement.naturalFixedWidth).toBe(212); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 1, + iconOnlyCount: 1, visible: true, }); }); diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts index fc39951723fa..82aa882db53a 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts @@ -42,6 +42,33 @@ function providerModelPickerMinimumWidth(picker: HTMLElement): number { return minWidth + elementInlineMarginWidth(picker); } +function controlBlockWidths(block: HTMLElement): { natural: number; iconOnly: number } { + const compact = block.dataset.composerBlockIconOnly === "true"; + let natural = elementOuterWidth(block); + let iconOnly = natural; + for (const label of block.querySelectorAll("[data-composer-control-label]")) { + // Labels remain mounted at natural width when icons replace them. Reading + // both variants from one tree avoids duplicate controls or write/read probes. + const labelStyle = getComputedStyle(label); + const inFlow = labelStyle.position !== "absolute"; + // Phone widths already hide the build label with sr-only. Its clipping + // remains in effect even when compact styles replace its one-pixel width. + if (!inFlow && (!compact || labelStyle.clip !== "auto")) continue; + const labelWidth = label.scrollWidth; + const renderedWidth = inFlow ? label.getBoundingClientRect().width : 0; + const gap = Number.parseFloat(getComputedStyle(label.parentElement!).columnGap) || 0; + natural += labelWidth - renderedWidth + (inFlow ? 0 : gap); + iconOnly -= renderedWidth + (inFlow ? gap : 0); + } + for (const icon of block.querySelectorAll("[data-composer-control-compact-icon]")) { + const width = elementOuterWidth(icon); + const gap = Number.parseFloat(getComputedStyle(icon.parentElement!).columnGap) || 0; + natural -= compact ? width + gap : 0; + iconOnly += compact ? 0 : width + gap; + } + return { natural, iconOnly: Math.min(natural, iconOnly) }; +} + /** * Read the natural widths of the resting composer controls from the DOM. * @@ -67,6 +94,7 @@ export function measureRestingComposerControls( const overflow = controls.querySelector("[data-resting-controls-overflow]"); const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; const blocks = Array.from(controls.querySelectorAll("[data-resting-block]")); + const widths = blocks.map(controlBlockWidths); return { gap, naturalFixedWidth: @@ -75,7 +103,8 @@ export function measureRestingComposerControls( minimumFixedWidth: (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + separatorAndGapWidth, - blockWidths: blocks.map(elementOuterWidth), + blockWidths: widths.map((width) => width.natural), + iconOnlyBlockWidths: widths.map((width) => width.iconOnly), overflowWidth: overflow ? elementOuterWidth(overflow) : 0, }; } diff --git a/apps/web/src/components/chat/useComposerTriggerState.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.test.tsx new file mode 100644 index 000000000000..6daf88bfbd67 --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.test.tsx @@ -0,0 +1,145 @@ +import { act, StrictMode, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { detectComposerTrigger } from "../../composer-logic"; +import { useComposerTriggerState } from "./useComposerTriggerState"; + +const command = "pnpm install -g @openai/codex@latest"; +const initialPrompt = "pnpm install -g @openai"; +let root: Root; +let composer: ReturnType; + +function ComposerProbe() { + const state = useComposerTriggerState(() => + detectComposerTrigger(initialPrompt, initialPrompt.length), + ); + useLayoutEffect(() => { + composer = state; + }); + return null; +} + +async function updatePrompt(text: string, cursor = text.length) { + await act(() => composer.setTrigger(detectComposerTrigger(text, cursor))); +} + +beforeEach(async () => { + // The probe renders no DOM nodes, but ReactDOM still needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + await act(() => + root.render( + + + , + ), + ); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("composer suggestion dismissal", () => { + it("closes suggestions and rejects keyboard selection before the next render", async () => { + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + expect(composer.trigger).toEqual(candidate); + + await act(() => { + composer.dismissTrigger(candidate); + expect(composer.resolveTrigger(candidate)).toBeNull(); + }); + expect(composer.trigger).toBeNull(); + }); + + it("stays dismissed while typing a scoped package, including its second @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) { + const text = command.slice(0, cursor); + await updatePrompt(text); + expect(composer.trigger).toBeNull(); + expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull(); + } + }); + + it("stays dismissed while deleting characters or moving within the same word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) { + await updatePrompt(initialPrompt, cursor); + expect(composer.trigger).toBeNull(); + await updatePrompt(initialPrompt.slice(0, cursor)); + expect(composer.trigger).toBeNull(); + } + }); + + it("opens suggestions for a new @ word after a space", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} `); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + expect(composer.trigger?.rangeStart).toBe(command.length + 1); + }); + + it("opens a different token when the caret moves directly to it", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + }); + + it("can reopen after the caret leaves the dismissed word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(initialPrompt, 0); + await updatePrompt(initialPrompt); + + expect(composer.trigger?.query).toBe("openai"); + }); + + it("can reopen at the same position after deleting and retyping @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@")); + await updatePrompt(prefix); + await updatePrompt(`${prefix}@`); + + expect(composer.trigger?.query).toBe(""); + }); + + it("clears dismissal when switching drafts or pending questions", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + await act(() => composer.resetTrigger(candidate)); + + expect(composer.trigger).toEqual(candidate); + expect(composer.resolveTrigger(candidate)).toEqual(candidate); + }); + + it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { + await updatePrompt(text); + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${text}x`); + + expect(composer.trigger).toBeNull(); + await updatePrompt("@src"); + expect(composer.trigger?.kind).toBe("path"); + }); +}); diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts new file mode 100644 index 000000000000..255a8c43735c --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -0,0 +1,42 @@ +import { useCallback, useRef, useState } from "react"; + +import type { ComposerTrigger } from "../../composer-logic"; + +/** Keep a dismissed suggestion closed until the caret leaves its token. */ +export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) { + const [trigger, setActiveTrigger] = useState(initialTrigger); + const dismissedTriggerRef = useRef(null); + + const resolveTrigger = useCallback((candidate: ComposerTrigger | null) => { + const dismissed = dismissedTriggerRef.current; + return candidate && + dismissed && + candidate.kind === dismissed.kind && + candidate.rangeStart === dismissed.rangeStart + ? null + : candidate; + }, []); + + const setTrigger = useCallback( + (candidate: ComposerTrigger | null) => { + const activeTrigger = resolveTrigger(candidate); + if (candidate === null || activeTrigger !== null) { + dismissedTriggerRef.current = null; + } + setActiveTrigger(activeTrigger); + }, + [resolveTrigger], + ); + + const dismissTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = candidate; + setActiveTrigger(null); + }, []); + + const resetTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = null; + setActiveTrigger(candidate); + }, []); + + return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger }; +} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 5c0e4327d959..6cb17427c02e 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -449,3 +449,79 @@ describe("resolveScrollToEndClearance", () => { } }); }); + +describe("progressive composer controls", () => { + const measurement = { + gap: 4, + naturalFixedWidth: 140, + minimumFixedWidth: 80, + blockWidths: [80, 140], + iconOnlyBlockWidths: [40, 60], + overflowWidth: 24, + }; + + it("keeps labels while they fit and removes trailing labels before controls", () => { + for (const [hostWidth, iconOnlyCount, hiddenCount] of [ + [368, 0, 0], + [367, 1, 0], + [288, 1, 0], + [287, 2, 0], + [248, 2, 0], + [247, 2, 1], + [211, 2, 2], + ] as const) { + expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth })).toEqual({ + hiddenCount, + iconOnlyCount, + visible: true, + }); + } + }); + + it("requires slack to restore labels and controls", () => { + for (const [hostWidth, previous, promoted] of [ + [ + 368, + { hiddenCount: 0, iconOnlyCount: 1, visible: true }, + { hiddenCount: 0, iconOnlyCount: 0, visible: true }, + ], + [ + 288, + { hiddenCount: 0, iconOnlyCount: 2, visible: true }, + { hiddenCount: 0, iconOnlyCount: 1, visible: true }, + ], + [ + 248, + { hiddenCount: 1, iconOnlyCount: 2, visible: true }, + { hiddenCount: 0, iconOnlyCount: 2, visible: true }, + ], + ] as const) { + expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth, previous })).toEqual( + previous, + ); + expect( + resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth: hostWidth + 1, + previous, + }), + ).toEqual(promoted); + } + }); + + it("settles through fractional label-width changes at each threshold", () => { + for (const hostWidth of [368, 288, 248]) { + let previous = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); + for (let index = 0; index < 10; index += 1) { + const next = resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth, + previous, + naturalFixedWidth: 140 + (index % 2) * 0.5, + }); + if (index > 1) expect(next).toEqual(previous); + previous = next; + } + } + }); +}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 42f466c2ae9d..c89edde9f449 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -113,18 +113,29 @@ export interface RestingComposerControlsMeasurement { minimumFixedWidth: number; blockWidths: readonly number[]; overflowWidth: number; + iconOnlyBlockWidths?: readonly number[]; } function restingComposerControlsWidth( input: RestingComposerControlsMeasurement, hiddenCount: number, fixedWidth = input.naturalFixedWidth, + iconOnlyCount = 0, ): number { const { blockWidths, gap } = input; const visibleCount = blockWidths.length - hiddenCount; return ( fixedWidth + - blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + + blockWidths + .slice(0, visibleCount) + .reduce( + (sum, width, index) => + sum + + (index >= blockWidths.length - iconOnlyCount + ? (input.iconOnlyBlockWidths?.[index] ?? width) + : width), + 0, + ) + (hiddenCount > 0 ? input.overflowWidth : 0) + gap * (visibleCount + (hiddenCount > 0 ? 1 : 0)) ); @@ -145,10 +156,11 @@ export function resolveRestingComposerControlsNaturalWidth( } /** - * Decide how many trailing resting control blocks move into the overflow - * menu, and whether the cluster can show at all, from natural widths. + * Fit footer controls using natural widths: remove trailing labels first, + * then move trailing blocks into overflow. Resting and expanded share this + * decision, including the slack needed to safely restore controls. * - * Trailing blocks hide before the model picker shrinks. Once they are all in + * Trailing blocks compact before the model picker shrinks. Once they are all in * the overflow menu, the picker may contract to its minimum readable width; * below that the whole cluster hides rather than clipping. */ @@ -157,39 +169,41 @@ const RESTING_CONTROLS_SLACK_PX = 1; export function resolveRestingComposerControlsLayout( input: RestingComposerControlsMeasurement & { hostWidth: number; - previous?: { hiddenCount: number; visible: boolean }; + previous?: { hiddenCount: number; iconOnlyCount?: number; visible: boolean }; }, -): { hiddenCount: number; visible: boolean } { +): { hiddenCount: number; iconOnlyCount?: number; visible: boolean } { const { blockWidths, hostWidth, previous } = input; - let hiddenCount = 0; + const iconSteps = input.iconOnlyBlockWidths ? blockWidths.length : 0; + const previousStep = previous + ? previous.hiddenCount > 0 + ? iconSteps + Math.min(previous.hiddenCount, blockWidths.length) + : Math.min(previous.iconOnlyCount ?? 0, iconSteps) + : 0; + let step = 0; + const widthAtStep = (candidate: number, fixedWidth = input.naturalFixedWidth) => + restingComposerControlsWidth( + input, + Math.max(0, candidate - iconSteps), + fixedWidth, + Math.min(candidate, iconSteps), + ); + // Promotions need a pixel of slack: recovering a flexible picker's natural + // width can jitter by a fraction of a pixel across renders. Demotions are + // immediate so a threshold cannot clip or flip React between layouts. while ( - hiddenCount < blockWidths.length && - restingComposerControlsWidth(input, hiddenCount) > hostWidth + step < iconSteps + blockWidths.length && + widthAtStep(step) > hostWidth - (step < previousStep ? RESTING_CONTROLS_SLACK_PX : 0) ) { - hiddenCount += 1; + step += 1; } - // Growing the overflow menu is unconditional, or the controls would clip. - // Shrinking it has to earn a pixel of slack first: the picker is flexible, - // so its natural width is recovered from a truncated label whose - // scrollWidth is integral while the rendered box is fractional. The - // composer re-measures on every render, so without that margin a host - // sitting exactly on a threshold flips a block in and out until React - // gives up with "Maximum update depth exceeded". - if (previous) { - const previousHiddenCount = Math.min(previous.hiddenCount, blockWidths.length); - while ( - hiddenCount < previousHiddenCount && - restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX - ) { - hiddenCount += 1; - } - } - const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth); + const hiddenCount = Math.max(0, step - iconSteps); + const iconOnlyCount = Math.min(step, iconSteps); + const minimumWidth = widthAtStep(step, input.minimumFixedWidth); const visible = previous && !previous.visible ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX : minimumWidth <= hostWidth; - return { hiddenCount, visible }; + return { hiddenCount, ...(input.iconOnlyBlockWidths ? { iconOnlyCount } : {}), visible }; } export function resolveScrollToEndClearance(input: { diff --git a/apps/web/src/components/device/DeviceHostUpdates.tsx b/apps/web/src/components/device/DeviceHostUpdates.tsx new file mode 100644 index 000000000000..bda9fca88f93 --- /dev/null +++ b/apps/web/src/components/device/DeviceHostUpdates.tsx @@ -0,0 +1,67 @@ +import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { deviceEnvironment } from "~/state/device"; +import { useAtomCommand } from "~/state/use-atom-command"; + +/** Shared by setup, Settings, and the Device panel so automatic updates stay visible. */ +export function DeviceHostUpdates({ + state, + environmentId, +}: { + state: DeviceServiceState; + environmentId: EnvironmentId; +}) { + const retry = useAtomCommand(deviceEnvironment.list); + const [pending, setPending] = useState(null); + if (state.hostStatus === "disabled") return null; + return ( +
+ {state.hosts.map((host) => { + const status = state.hostStatuses[host.id]; + if (!status || !["installing", "starting", "failed"].includes(status.status)) return null; + const failed = status.status === "failed"; + return ( +
+
+

{host.label}

+

+ {status.detail ?? + (failed + ? "Device support could not start." + : status.status === "installing" + ? "Installing device tools…" + : "Starting device tools…")} +

+ {failed ? ( +

+ Check the host connection and network access, then retry. Your device settings are + saved. +

+ ) : null} +
+ {failed && state.supportsHostRetry ? ( + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 6f1ae81eddb9..130353943352 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "./DeviceHostUpdates"; import type { DevicePlatform, DeviceServiceState, @@ -253,6 +254,7 @@ export function DevicePanel(props: { {state.hostStatusDetail}
) : null} + {bootingDevices.length > 0 ? (
Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute. @@ -319,7 +321,7 @@ export function DevicePanel(props: { ? "Opening device…" : "Starting device…" : state.hostStatus === "installing" - ? "Installing device support…" + ? (state.hostStatusDetail ?? "Installing device support…") : "Finding devices…" } /> diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 67bf14181b21..5ef41b4cefd9 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "./DeviceHostUpdates"; import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; import { Check, CircleAlert } from "lucide-react"; import { useState } from "react"; @@ -89,6 +90,7 @@ export function DeviceSetup(props: { + {step === 0 ? (

Enable the device hub

diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx new file mode 100644 index 000000000000..d9a79f4bddb5 --- /dev/null +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -0,0 +1,89 @@ +import type { ReactNode } from "react"; +import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; + +export function DeviceToolVersions({ + tools, + action, + kind, + owner, + error, +}: { + tools: ToolVersions | undefined; + action?: ReactNode; + kind?: keyof ToolVersions; + owner?: string | undefined; + error?: string | undefined; +}) { + const selected = kind ? tools?.[kind] : undefined; + const version = + selected?.runningVersion ?? + (selected?.installedVersions.includes(selected.requiredVersion) + ? selected.requiredVersion + : selected?.installedVersions + .toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .at(-1)); + const label = kind === "hub" ? "Device hub" : "Agent device"; + return ( + + + {kind + ? version + ? `v${version}` + : selected + ? "Not installed" + : "Version unknown" + : error + ? "Versions unavailable" + : "Versions"} + + + {kind ? label : "Device tools"} + {tools ? ( +
+ {( + [ + ["Device hub", tools.hub], + ["Agent device", tools.agent], + ] as const + ) + .filter(([name]) => !kind || name === label) + .map(([name, tool]) => ( +
+ {!kind ?

{name}

: null} +
+
Running
+
{tool.runningVersion ?? "Not running"}
+
Required
+
{tool.requiredVersion}
+
Installed
+
+ {tool.installedVersions.join(", ") || "None"} +
+
+
+ ))} +
+ ) : ( +

Versions have not been checked.

+ )} +

+ {owner ? `Managed by ${owner}. ` : ""}Tools update automatically on this host when needed. +

+ {error ? ( +

+ {error} +

+ ) : null} + {action ?
{action}
: null} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 44b70f2e18ff..f19e41599043 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -121,6 +121,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; @@ -848,6 +849,9 @@ export function PullRequestDetailPanel({ // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); const [isInvalidating, setIsInvalidating] = useState(false); + // One word for "the host is being asked again", whichever of the two halves is in flight: + // the invalidation round trip, then the detail read it kicks off. + const refreshing = isInvalidating || detailQuery.isPending; const refreshFromHost = useCallback(async () => { setIsInvalidating(true); try { @@ -1986,18 +1990,29 @@ export function PullRequestDetailPanel({ } > - + {/* The refresh lives in this menu, so while one runs the trigger wears + the spinning glyph in place of the dots: the reader sees the panel + is fetching without a control appearing or the row shifting. */} + {refreshing ? ( + + ) : ( + + )} } /> - More pull request actions + + {refreshing ? "Refreshing pull request" : "More pull request actions"} + - void refreshFromHost()} - > - + void refreshFromHost()}> + Refresh @@ -2267,7 +2276,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + + ) : ( @@ -2280,7 +2291,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + + } /> @@ -2298,7 +2311,9 @@ export function PullRequestDetailPanel({ {detail.headBranch} + + + } /> {detail.headBranch} @@ -2452,7 +2467,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + + ) : ( @@ -2465,7 +2482,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + + } /> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 40a0af103bc0..d30fe5109703 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -27,6 +27,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button, InlineButton } from "../ui/button"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActorLabel, @@ -252,7 +253,9 @@ export function PullRequestDetailGhost({ {seed ? ( - {seed.baseBranch} + + + ) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index e77f82717698..ea1577a23558 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestActorAvatar, @@ -148,11 +149,17 @@ export function PullRequestRowAuthor({ ); } -/** `head → base`, in the mono the branches are typed in. */ +/** + * `head → base`, in the mono the branches are typed in, each cut in the middle when the row is + * short of room. The base keeps its width up to a share of the line, so a long head cannot + * squeeze a short `main` out; the arrow stays readable so the two are not read as one name. + */ export function PullRequestRowBranches({ head, base }: { head: string; base: string }) { return ( - - {head} → {base} + + + → + ); } diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index 50841ee5b53a..ffed54e14643 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -1,10 +1,11 @@ +import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { AppleIcon, AndroidIcon } from "../Icons"; import { Spinner } from "../ui/spinner"; import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; import { randomUUID } from "../../lib/utils"; import { useState } from "react"; -import { useDeviceState } from "../../state/device"; +import { deviceEnvironment, useDeviceState } from "../../state/device"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; @@ -109,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null

) : null} ; busy: boolean; @@ -174,6 +178,8 @@ function DeviceHostList({ testConnection: ReturnType["testConnection"]; }) { const { state } = useDeviceState(environmentId); + const retry = useAtomCommand(deviceEnvironment.list); + const [retrying, setRetrying] = useState(null); return ( <> {hosts.length === 0 ? ( @@ -236,6 +242,14 @@ function DeviceHostList({ ))}

{host.target}

+ value.id === host.id)?.toolInspectionError} + tools={ + state.hosts.find((value) => value.id === host.id)?.tools ?? + (check?.status === "connected" ? check.tools : undefined) + } + /> {check?.status === "local" ? (

Already available locally

) : null} @@ -283,14 +297,32 @@ function DeviceHostList({ - + {status?.status === "failed" && + state.supportsHostRetry && + state.hostStatus !== "disabled" ? ( + + ) : ( + + )}
); })} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 60757724f2e4..b6b4a77b7c04 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,5 @@ +import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; +import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; import { DeviceHostsSettings } from "./DeviceHostsSettings"; @@ -640,7 +642,9 @@ function DeviceIntegrationControls({ ); const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); - const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); + const [pending, setPending] = useState< + "hub" | "check" | "agent" | "update-hub" | "update-agent" | null + >(null); const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; const [platformsRevealed, setPlatformsRevealed] = useState(false); // Keep diagnostics visible through subsequent agent setup and refresh phases. @@ -683,6 +687,65 @@ function DeviceIntegrationControls({ } }; + const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>( + null, + ); + const localTools = state.hosts.find((host) => host.kind === "local")?.tools; + const versionActions = (tool: "hub" | "agent") => { + const version = localTools?.[tool]; + const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion); + return ( +
+
+ {state.supportsToolUpdate && needsUpdate ? ( + + ) : null} + {state.supportsToolInspection ? ( + + ) : null} +
+ {updateError?.tool === tool ? ( +

+ {updateError.message} +

+ ) : null} +
+ ); + }; + return ( <> + host.kind === "local")?.tools} + /> {pending === "hub" ? : null} + host.kind === "local")?.tools} + /> {pending === "agent" ? : null} } /> - {state.hostStatus === "failed" && state.hostStatusDetail ? ( -

- {state.hostStatusDetail} -

+ {environmentId ? ( + host.kind === "local") }} + environmentId={environmentId} + /> ) : null} diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts index 97312b671513..0626d1854336 100644 --- a/apps/web/src/components/settings/deviceHostConnectionChecks.ts +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -14,7 +14,11 @@ export interface DeviceHostCheckTarget { export type DeviceHostCheck = | { status: "pending" } | { status: "local" } - | { status: "connected"; platforms: ReadonlyArray } + | { + status: "connected"; + platforms: ReadonlyArray; + tools?: DeviceHostSummary["tools"]; + } | { status: "failed"; error: string }; const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); @@ -48,7 +52,11 @@ export async function checkDeviceHostConnections( target.environmentId, result.kind === "local" ? { status: "local" } - : { status: "connected", platforms: result.platforms }, + : { + status: "connected", + platforms: result.platforms, + ...(result.tools ? { tools: result.tools } : {}), + }, ); } catch (error) { report(target.environmentId, { diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx new file mode 100644 index 000000000000..fd8a25447738 --- /dev/null +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -0,0 +1,73 @@ +import type { ComponentProps } from "react"; + +import { cn } from "~/lib/utils"; + +/** + * Truncates in the middle, the way Finder does, for strings that carry meaning at both ends: + * branch names, paths, worktree names, shas. A tail-cut `fix/cache-main-20260918-180825` loses + * the date that tells two branches apart; a path loses its file name. + * + * CSS has no middle ellipsis, so the string is split into a head that truncates and a tail + * that does not. No measuring and no observers, so it costs the same as `truncate` in a long + * list and works inside `content-visibility` skipping. Both halves are real text: selection + * and copy give the whole string, and a screen reader reads it through. + */ +export function MiddleTruncate({ + value, + tail, + showTitle = true, + className, + ...props +}: Omit, "children"> & { + value: string; + /** Characters kept at the end. Defaults to the last path segment, capped, or 10. */ + tail?: number; + /** The full value on hover. Off where a tooltip already carries it. */ + showTitle?: boolean; +}) { + const split = splitForMiddleTruncate(value, tail); + return ( + + {split ? ( + <> + {split.head} + {split.tail} + + ) : ( + {value} + )} + + ); +} + +const DEFAULT_TAIL = 10; +const MAX_SEGMENT_TAIL = 16; + +/** + * Where to cut. A path keeps its last segment when that is short enough to be the useful + * part; anything else keeps a fixed count. Nothing is split when the tail would be most of + * the string, since then the head could never show enough to be worth an ellipsis. + */ +export function splitForMiddleTruncate( + value: string, + tail?: number, +): { head: string; tail: string } | null { + // Code points, not UTF-16 units: a cut inside a surrogate pair would render two broken + // glyphs where an emoji or a CJK extension character used to be. + const chars = Array.from(value); + let keep = tail ?? DEFAULT_TAIL; + if (tail === undefined) { + const slash = chars.lastIndexOf("/"); + if (slash > 0 && slash < chars.length - 1) { + const segment = chars.length - slash - 1; + keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL; + } + } + if (keep <= 0 || chars.length <= keep + 4) return null; + const cut = chars.length - keep; + return { head: chars.slice(0, cut).join(""), tail: chars.slice(cut).join("") }; +} diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index a8798708974b..8fa311b1999c 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -38,7 +39,9 @@ const schema = getSchemaByResolvedExtensions( dropcursor: false, gapcursor: false, trailingNode: false, + code: false, }), + ComposerCodeExtension, stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), stubAtom("composer-skill", { skillName: { default: "" }, @@ -64,6 +67,8 @@ const schema = getSchemaByResolvedExtensions( function roundTrip(value: string) { const json = buildDocJson(value, (name) => ({ label: name, description: null })); const doc = ProseMirrorNode.fromJSON(schema, json); + // `insertContent` validates every node against the schema; `fromJSON` does not. + doc.check(); return serializeEditorDoc(doc); } @@ -135,6 +140,10 @@ describe("composer rich text document model", () => { "hello **bold** world", "a *italic* word and `code` here", "struck ~~out~~ now", + "**`x`**", + "*`x`*", + "~~`x`~~", + "**a `code` c**", "***bold italic*** keeps nesting", "line one\nline two", "trailing newline\n", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index e6cedcc5abb3..ed83c35b5f5b 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,4 +1,5 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Code } from "@tiptap/extension-code"; import { TaskItem } from "@tiptap/extension-task-item"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; @@ -41,6 +42,13 @@ const TIPTAP_TO_MARK: Record = { code: "code", }; +/** + * Tiptap's code mark excludes every other mark, which rejects the `bold+code` + * spans markdown like `**\`x\`**` parses into and drops the whole insert. + * Code nests inside emphasis here, so it only excludes itself like the rest. + */ +export const ComposerCodeExtension = Code.extend({ excludes: "code" }); + /** * Task list items keep their exact source indent in an attribute so nesting * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` — diff --git a/apps/web/src/hooks/showUndoToast.test.ts b/apps/web/src/hooks/showUndoToast.test.ts new file mode 100644 index 000000000000..ced0b2bc5504 --- /dev/null +++ b/apps/web/src/hooks/showUndoToast.test.ts @@ -0,0 +1,127 @@ +import { AsyncResult } from "effect/unstable/reactivity"; +import * as Cause from "effect/Cause"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { toastManager } from "../components/ui/toast"; +import { showUndoToast, undoLatestThreadAction } from "./showUndoToast"; +import * as ThreadUndo from "./threadUndo"; + +afterEach(() => vi.restoreAllMocks()); + +function setup() { + const add = vi.spyOn(toastManager, "add").mockReturnValue("undo-toast"); + const close = vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const undo = vi.fn(async () => AsyncResult.success(undefined)); + const claim = ThreadUndo.begin("pin", "env/thread"); + const options = { + title: "Thread unpinned", + description: "Thread", + failureTitle: "Restore failed", + undo, + claim, + }; + return { add, close, undo, claim, options }; +} + +function click(add: ReturnType["add"], index = 0) { + const handler = add.mock.calls[index]?.[0].actionProps?.onClick; + if (!handler) throw new Error("Undo action is missing"); + return handler({} as Parameters[0]); +} + +describe("showUndoToast", () => { + it("ignores a stale toast and lets the latest action run only once", async () => { + const { add, close, undo, options } = setup(); + showUndoToast(options); + ThreadUndo.invalidate("pin", "env/thread"); + showUndoToast({ ...options, claim: ThreadUndo.begin("pin", "env/thread") }); + await click(add); + expect(undo).not.toHaveBeenCalled(); + await click(add, 1); + await click(add, 1); + expect(undo).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledExactlyOnceWith("undo-toast"); + }); + + it("releases the claim on close and rejects a later click", async () => { + const { add, undo, claim, options } = setup(); + showUndoToast(options); + add.mock.calls[0]?.[0].onClose?.(); + expect(claim.isCurrent()).toBe(false); + await click(add); + expect(undo).not.toHaveBeenCalled(); + }); + + it("does not show a toast for a late completion after a newer action", () => { + const { add, options } = setup(); + ThreadUndo.invalidate("pin", "env/thread"); + showUndoToast(options); + expect(add).not.toHaveBeenCalled(); + }); + + it("reports a failed restore and releases its claim", async () => { + const { add, claim, options } = setup(); + showUndoToast({ + ...options, + undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))), + }); + await click(add); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }), + ); + expect(claim.isCurrent()).toBe(false); + }); + + it("reports a rejected restore promise", async () => { + const { add, options } = setup(); + showUndoToast({ + ...options, + undo: async () => { + throw new Error("disconnected"); + }, + }); + await click(add); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", description: "disconnected" }), + ); + }); + + it("does not report interrupted restores as errors", async () => { + const { add, options } = setup(); + showUndoToast({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) }); + await click(add); + expect(add).toHaveBeenCalledOnce(); + }); +}); + +describe("undoLatestThreadAction", () => { + it("runs the newest live Undo once and then reports nothing to undo", () => { + const { options } = setup(); + const older = vi.fn(async () => AsyncResult.success(undefined)); + const newer = vi.fn(async () => AsyncResult.success(undefined)); + showUndoToast({ ...options, undo: older, claim: ThreadUndo.begin("settle", "env/a") }); + showUndoToast({ ...options, undo: newer, claim: ThreadUndo.begin("snooze", "env/b") }); + expect(undoLatestThreadAction()).toBe(true); + expect(newer).toHaveBeenCalledOnce(); + expect(older).not.toHaveBeenCalled(); + expect(undoLatestThreadAction()).toBe(true); + expect(older).toHaveBeenCalledOnce(); + expect(undoLatestThreadAction()).toBe(false); + }); + + it("skips a superseded toast and a closed toast", () => { + const { add, options } = setup(); + const superseded = vi.fn(async () => AsyncResult.success(undefined)); + const closed = vi.fn(async () => AsyncResult.success(undefined)); + const live = vi.fn(async () => AsyncResult.success(undefined)); + showUndoToast({ ...options, undo: live, claim: ThreadUndo.begin("archive", "env/live") }); + showUndoToast({ ...options, undo: closed, claim: ThreadUndo.begin("archive", "env/closed") }); + add.mock.calls[1]?.[0].onClose?.(); + showUndoToast({ ...options, undo: superseded, claim: ThreadUndo.begin("pin", "env/stale") }); + ThreadUndo.invalidate("pin", "env/stale"); + expect(undoLatestThreadAction()).toBe(true); + expect(superseded).not.toHaveBeenCalled(); + expect(closed).not.toHaveBeenCalled(); + expect(live).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/hooks/showUndoToast.ts b/apps/web/src/hooks/showUndoToast.ts new file mode 100644 index 000000000000..14d532411e7f --- /dev/null +++ b/apps/web/src/hooks/showUndoToast.ts @@ -0,0 +1,87 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import type * as ThreadUndo from "./threadUndo"; + +// Undo toasts still on screen, oldest first, so the `thread.undo` shortcut +// mirrors the newest toast's button without knowing which action it was. +const liveUndos: Array<() => Promise | null> = []; + +/** Runs the newest Undo whose claim still holds; false when nothing is left to undo. */ +export function undoLatestThreadAction(): boolean { + // A superseded entry drops itself when tried, so keep going until one + // runs or the list is empty. + while (liveUndos.length > 0) { + if (liveUndos[liveUndos.length - 1]?.() !== null) return true; + } + return false; +} + +/** Shows a single-use Undo while its thread action still owns the claim. */ +export function showUndoToast({ + title, + description, + undo, + failureTitle, + claim, +}: { + title: string; + description: string | undefined; + undo: () => Promise>; + failureTitle: string; + claim: ReturnType; +}) { + if (!claim.isCurrent()) return; + let undoStarted = false; + let toastId: string | undefined; + const reportFailure = (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }; + const forget = () => { + const index = liveUndos.indexOf(run); + if (index !== -1) liveUndos.splice(index, 1); + }; + const run = () => { + forget(); + if (undoStarted || !claim.isCurrent()) return null; + undoStarted = true; + claim.finish(); + if (toastId !== undefined) toastManager.close(toastId); + return undo() + .then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure(squashAtomCommandFailure(result)); + } + }) + .catch(reportFailure); + }; + liveUndos.push(run); + toastId = toastManager.add({ + ...stackedThreadToast({ + type: "success", + title, + description, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: async () => { + await run(); + }, + }, + }), + onClose: () => { + claim.finish(); + forget(); + }, + }); +} diff --git a/apps/web/src/hooks/threadUndo.test.ts b/apps/web/src/hooks/threadUndo.test.ts new file mode 100644 index 000000000000..58762c625449 --- /dev/null +++ b/apps/web/src/hooks/threadUndo.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import * as ThreadUndo from "./threadUndo"; + +describe("thread action ownership", () => { + it("keeps claims for different action kinds independent", () => { + const pin = ThreadUndo.begin("pin", "env/shared"); + const archive = ThreadUndo.begin("archive", "env/shared"); + ThreadUndo.invalidate("pin", "env/shared"); + expect(pin.isCurrent()).toBe(false); + expect(archive.isCurrent()).toBe(true); + archive.finish(); + }); + + it("does not revive the first Undo after a later pin and unpin", () => { + const firstUnpin = ThreadUndo.begin("pin", "env/thread"); + ThreadUndo.invalidate("pin", "env/thread"); + const secondUnpin = ThreadUndo.begin("pin", "env/thread"); + expect(firstUnpin.isCurrent()).toBe(false); + expect(secondUnpin.isCurrent()).toBe(true); + firstUnpin.finish(); + expect(secondUnpin.isCurrent()).toBe(true); + secondUnpin.finish(); + expect(firstUnpin.isCurrent()).toBe(false); + }); + + it("rejects a late unpin completion after a newer pin started", () => { + const pendingUnpin = ThreadUndo.begin("pin", "env/late"); + ThreadUndo.invalidate("pin", "env/late"); + expect(pendingUnpin.isCurrent()).toBe(false); + }); + + it("expires an Undo without invalidating another environment or thread", () => { + const first = ThreadUndo.begin("pin", "one/thread"); + const otherEnvironment = ThreadUndo.begin("pin", "two/thread"); + const otherThread = ThreadUndo.begin("pin", "one/other"); + first.finish(); + expect(first.isCurrent()).toBe(false); + expect(otherEnvironment.isCurrent()).toBe(true); + expect(otherThread.isCurrent()).toBe(true); + otherEnvironment.finish(); + otherThread.finish(); + }); +}); diff --git a/apps/web/src/hooks/threadUndo.ts b/apps/web/src/hooks/threadUndo.ts new file mode 100644 index 000000000000..a9e5c179fc87 --- /dev/null +++ b/apps/web/src/hooks/threadUndo.ts @@ -0,0 +1,21 @@ +// Shared across hook instances so sidebar, header and menu actions invalidate each other. +const currentActions = new Map(); + +/** Claims one kind of thread action; a later claim of that kind expires its Undo. */ +export function begin(kind: string, threadKey: string) { + const key = JSON.stringify([kind, threadKey]); + const token = Symbol(); + currentActions.set(key, token); + const isCurrent = () => currentActions.get(key) === token; + return { + isCurrent, + finish: () => { + if (isCurrent()) currentActions.delete(key); + }, + }; +} + +/** Expires only this action kind, leaving unrelated thread actions intact. */ +export function invalidate(kind: string, threadKey: string) { + currentActions.delete(JSON.stringify([kind, threadKey])); +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index b4bf973f0231..8a24c5110fa7 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -11,7 +11,7 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; -import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; +import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, type ThreadActionMenuId, @@ -162,29 +162,9 @@ export function useThreadActionMenu(input: { : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); if (!preset) return; const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); - } - return; + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); } - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => { - void unsnoozeThread(threadRef).then((undone) => { - if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { - failureToast("Failed to wake thread", squashAtomCommandFailure(undone)); - } - }); - }, - }, - }), - ); return; } const reportFailure = async ( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 55ff9992ce57..40a06ba9676a 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; +import { snoozeWakeDescription } from "../components/Sidebar.snooze"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -42,6 +43,8 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; +import * as ThreadUndo from "./threadUndo"; +import { showUndoToast } from "./showUndoToast"; import { useAtomCommand } from "../state/use-atom-command"; export class ThreadArchiveBlockedError extends Schema.TaggedError()( @@ -220,6 +223,7 @@ export function useThreadActions() { const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); + const timestampFormat = useClientSettings((settings) => settings.timestampFormat); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -250,6 +254,30 @@ export function useThreadActions() { return resolveThreadRouteRef(currentRouteParams); }, [router]); + const unarchiveThread = useCallback( + async (target: ScopedThreadRef, opts: { navigate?: boolean } = {}) => { + ThreadUndo.invalidate("archive", scopedThreadKey(target)); + const result = await unarchiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Failure") { + return result; + } + refreshArchivedThreadsForEnvironment(target.environmentId); + if (opts.navigate) { + return settlePromise(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(target), + }), + ); + } + return result; + }, + [router, unarchiveThreadMutation], + ); + const archiveThread = useCallback( async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { const resolved = resolveThreadTarget(target); @@ -270,11 +298,13 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; + const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); if (archiveResult._tag === "Failure") { + action.finish(); return archiveResult; } const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); @@ -283,6 +313,14 @@ export function useThreadActions() { } refreshArchivedThreadsForEnvironment(threadRef.environmentId); opts.onArchived?.(); + showUndoToast({ + title: "Thread archived", + description: thread.title, + claim: action, + // Undo also brings the reader back when archiving moved them to a draft. + undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), + failureTitle: "Failed to undo archive", + }); if (shouldNavigateToDraft) { const navigationResult = await settlePromise(() => @@ -296,21 +334,13 @@ export function useThreadActions() { return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget], - ); - - const unarchiveThread = useCallback( - async (target: ScopedThreadRef) => { - const result = await unarchiveThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, - }); - if (result._tag === "Success") { - refreshArchivedThreadsForEnvironment(target.environmentId); - } - return result; - }, - [unarchiveThreadMutation], + [ + archiveThreadMutation, + getCurrentRouteThreadRef, + markThreadVisited, + resolveThreadTarget, + unarchiveThread, + ], ); const deleteThread = useCallback( @@ -506,38 +536,6 @@ export function useThreadActions() { ], ); - const settleThread = useCallback( - async (target: ScopedThreadRef) => { - // Version skew: never send the command to a server that predates it — - // the raw protocol rejection would read as a random failure. - if (!readEnvironmentSupportsSettlement(target.environmentId)) { - return AsyncResult.failure( - Cause.fail( - new ThreadSettlementUnsupportedError({ - environmentId: target.environmentId, - threadId: target.threadId, - }), - ), - ); - } - const resolved = resolveThreadTarget(target); - const wokeAt = resolved - ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) - : null; - // Settle is a high-frequency lifecycle action and stays silent — no - // toast. - const result = await settleThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, - }); - if (result._tag === "Success" && wokeAt !== null) { - markThreadVisited(scopedThreadKey(target), wokeAt); - } - return result; - }, - [markThreadVisited, resolveThreadTarget, settleThreadMutation], - ); - const unsettleThread = useCallback( async (target: ScopedThreadRef) => { if (!readEnvironmentSupportsSettlement(target.environmentId)) { @@ -550,6 +548,7 @@ export function useThreadActions() { ), ); } + ThreadUndo.invalidate("settle", scopedThreadKey(target)); // reason "user" pins the thread active: auto-settle (PR merged / // inactivity) stays suppressed until real activity clears the pin. return unsettleThreadMutation({ @@ -582,6 +581,7 @@ export function useThreadActions() { const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) ? (opts.orderKey ?? topOfPinnedRunOrderKey()) : undefined; + ThreadUndo.invalidate("pin", scopedThreadKey(target)); return pinThreadMutation({ environmentId: target.environmentId, input: { @@ -605,12 +605,110 @@ export function useThreadActions() { ), ); } - return unpinThreadMutation({ + const thread = readThreadShell(target); + const orderKey = thread?.pinOrderKey ?? undefined; + const action = ThreadUndo.begin("pin", scopedThreadKey(target)); + const result = await unpinThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Success" && action.isCurrent()) { + showUndoToast({ + title: "Thread unpinned", + description: thread?.title, + claim: action, + undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }), + failureTitle: "Failed to undo unpin", + }); + } else { + action.finish(); + } + return result; + }, + [pinThread, unpinThreadMutation], + ); + + const settleThread = useCallback( + async ( + target: ScopedThreadRef, + // Batch callers settle a selection at once and stay silent as before. + opts: { undoToast?: boolean } = {}, + ) => { + // Version skew: never send the command to a server that predates it — + // the raw protocol rejection would read as a random failure. + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + const resolved = resolveThreadTarget(target); + const wokeAt = resolved + ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) + : null; + // Settling also drops the pin and the snooze server-side, so Undo + // has to put those back as well. + const pinOrderKey = resolved?.thread.pinnedAt != null ? resolved.thread.pinOrderKey : null; + const wasPinned = resolved?.thread.pinnedAt != null; + const snoozedUntil = resolved?.thread.snoozedUntil ?? null; + // An older unpin/snooze Undo would re-pin or re-snooze, and the server + // treats either as a promotion that un-settles; settling supersedes them. + ThreadUndo.invalidate("pin", scopedThreadKey(target)); + ThreadUndo.invalidate("snooze", scopedThreadKey(target)); + const action = ThreadUndo.begin("settle", scopedThreadKey(target)); + const result = await settleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + if (result._tag !== "Success") { + action.finish(); + return result; + } + if (wokeAt !== null) { + markThreadVisited(scopedThreadKey(target), wokeAt); + } + if (opts.undoToast === false) { + action.finish(); + return result; + } + showUndoToast({ + title: "Thread settled", + description: resolved?.thread.title, + claim: action, + undo: async () => { + const unsettled = await unsettleThread(target); + if (unsettled._tag !== "Success") return unsettled; + if (wasPinned) { + const pinned = await pinThread( + target, + pinOrderKey == null ? {} : { orderKey: pinOrderKey }, + ); + if (pinned._tag !== "Success") return pinned; + } + if (snoozedUntil !== null) { + return snoozeThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil }, + }); + } + return unsettled; + }, + failureTitle: "Failed to undo settle", + }); + return result; }, - [unpinThreadMutation], + [ + markThreadVisited, + pinThread, + resolveThreadTarget, + settleThreadMutation, + snoozeThreadMutation, + unsettleThread, + ], ); const confirmAndUnpinThread = useCallback( @@ -648,6 +746,7 @@ export function useThreadActions() { ), ); } + ThreadUndo.invalidate("pin", scopedThreadKey(target)); return reorderPinnedThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, orderKey }, @@ -676,8 +775,34 @@ export function useThreadActions() { [reorderActiveThreadMutation], ); + const unsnoozeThread = useCallback( + async (target: ScopedThreadRef) => { + if (!readEnvironmentSupportsSnooze(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSnoozeUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + ThreadUndo.invalidate("snooze", scopedThreadKey(target)); + return unsnoozeThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }, + [unsnoozeThreadMutation], + ); + const snoozeThread = useCallback( - async (target: ScopedThreadRef, snoozedUntil: string) => { + async ( + target: ScopedThreadRef, + snoozedUntil: string, + // Batch callers report one toast for the whole selection instead. + opts: { undoToast?: boolean } = {}, + ) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsSnooze(target.environmentId)) { return AsyncResult.failure( @@ -703,32 +828,26 @@ export function useThreadActions() { ), ); } - return snoozeThreadMutation({ + const action = ThreadUndo.begin("snooze", scopedThreadKey(target)); + const result = await snoozeThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, snoozedUntil }, }); - }, - [resolveThreadTarget, snoozeThreadMutation], - ); - - const unsnoozeThread = useCallback( - async (target: ScopedThreadRef) => { - if (!readEnvironmentSupportsSnooze(target.environmentId)) { - return AsyncResult.failure( - Cause.fail( - new ThreadSnoozeUnsupportedError({ - environmentId: target.environmentId, - threadId: target.threadId, - }), - ), - ); + if (result._tag !== "Success" || opts.undoToast === false) { + action.finish(); + return result; } - return unsnoozeThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId, reason: "user" }, + // Snooze hides the row, so the toast is the only confirmation. + showUndoToast({ + title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`, + description: resolved?.thread.title, + claim: action, + undo: () => unsnoozeThread(target), + failureTitle: "Failed to wake thread", }); + return result; }, - [unsnoozeThreadMutation], + [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread], ); const confirmAndDeleteThread = useCallback( diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts new file mode 100644 index 000000000000..eacffd146e20 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -0,0 +1,233 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useThreadActions } from "./useThreadActions"; +import { threadEnvironment } from "../state/threads"; +import { toastManager } from "../components/ui/toast"; + +const commands = vi.hoisted(() => ({ + pin: vi.fn(), + unpin: vi.fn(), + archive: vi.fn(), + unarchive: vi.fn(), + settle: vi.fn(), + unsettle: vi.fn(), + snooze: vi.fn(), + unsnooze: vi.fn(), +})); +const router = vi.hoisted(() => ({ + navigate: vi.fn(async () => {}), + state: { matches: [{ params: {} as Record }] }, +})); +vi.mock("react", async (original) => ({ + ...(await original()), + useCallback: (callback: unknown) => callback, + useMemo: (create: () => unknown) => create(), + useRef: (value: unknown) => ({ current: value }), +})); +vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); +vi.mock("./useSettings", () => ({ useClientSettings: () => false })); +vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); +vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); +vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); +vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() })); +const threadShell = vi.hoisted(() => ({ + title: "Thread", + pinOrderKey: "a0", + pinnedAt: null as string | null, + snoozedUntil: null as string | null, + projectId: "project", + environmentId: "undo-env", + session: null, +})); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + readEnvironmentSupportsPinning: () => true, + readEnvironmentSupportsPinReorder: () => true, + readEnvironmentSupportsSettlement: () => true, + readEnvironmentSupportsSnooze: () => true, + readThreadShell: () => threadShell, +})); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => { + switch (command) { + case threadEnvironment.pin: + return commands.pin; + case threadEnvironment.unpin: + return commands.unpin; + case threadEnvironment.archive: + return commands.archive; + case threadEnvironment.unarchive: + return commands.unarchive; + case threadEnvironment.settle: + return commands.settle; + case threadEnvironment.unsettle: + return commands.unsettle; + case threadEnvironment.snooze: + return commands.snooze; + case threadEnvironment.unsnooze: + return commands.unsnooze; + default: + return vi.fn(); + } + }, +})); + +const target = { + environmentId: EnvironmentId.make("undo-env"), + threadId: ThreadId.make("thread"), +}; +const event = {} as Parameters["onClick"]>>[0]; + +function undoOf( + add: { mock: { calls: Array<[Parameters[0]]> } }, + index: number, +) { + const onClick = add.mock.calls[index]?.[0].actionProps?.onClick; + expect(onClick).toBeTypeOf("function"); + return () => onClick?.(event); +} + +beforeEach(() => { + for (const command of Object.values(commands)) { + command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + } + router.navigate.mockClear(); + router.state.matches[0]!.params = {}; + threadShell.pinnedAt = null; + threadShell.snoozedUntil = null; +}); +afterEach(() => vi.restoreAllMocks()); + +describe("unpin Undo", () => { + it("ignores an old toast across hook instances and still restores the latest unpin", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const sidebar = useThreadActions(); + const header = useThreadActions(); + await sidebar.unpinThread(target); + const staleUndo = undoOf(add, 0); + await header.pinThread(target, { orderKey: "a1" }); + await header.unpinThread(target); + const latestUndo = undoOf(add, 1); + await staleUndo(); + expect(commands.pin).toHaveBeenCalledTimes(1); + await latestUndo(); + expect(commands.pin).toHaveBeenCalledTimes(2); + expect(commands.pin).toHaveBeenLastCalledWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey: "a0" }, + }); + await latestUndo(); + expect(commands.pin).toHaveBeenCalledTimes(2); + }); +}); + +describe("archive Undo", () => { + it("unarchives and returns to the thread when archiving left it", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + const actions = useThreadActions(); + await actions.archiveThread(target); + expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread archived" })); + await undoOf(add, 0)(); + expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + expect(router.navigate).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/$environmentId/$threadId", + params: { environmentId: target.environmentId, threadId: target.threadId }, + }), + ); + }); + + it("stays put when the archived thread was not open", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.archiveThread(target); + await undoOf(add, 0)(); + expect(commands.unarchive).toHaveBeenCalledOnce(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("shows no Undo when the archive failed", async () => { + commands.archive.mockResolvedValue({ _tag: "Failure", cause: new Error("nope") }); + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + await useThreadActions().archiveThread(target); + expect(add).not.toHaveBeenCalled(); + }); +}); + +describe("settle and snooze Undo", () => { + it("un-settles from the toast and expires the Undo after a manual un-settle", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.settleThread(target); + expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread settled" })); + const undo = undoOf(add, 0); + await actions.unsettleThread(target); + await undo(); + expect(commands.unsettle).toHaveBeenCalledOnce(); + }); + + it("re-pins and re-snoozes a thread that settling had cleared", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const snoozedUntil = "2030-01-01T09:00:00.000Z"; + threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; + threadShell.snoozedUntil = snoozedUntil; + const actions = useThreadActions(); + await actions.settleThread(target); + await undoOf(add, 0)(); + expect(commands.unsettle).toHaveBeenCalledOnce(); + expect(commands.pin).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey: "a0" }, + }); + expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil }, + }); + }); + + it("expires an older unpin Undo when the thread is settled", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.unpinThread(target); + const staleUnpinUndo = undoOf(add, 0); + await actions.settleThread(target); + await staleUnpinUndo(); + expect(commands.pin).not.toHaveBeenCalled(); + }); + + it("stays silent for batch settles", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + await useThreadActions().settleThread(target, { undoToast: false }); + expect(add).not.toHaveBeenCalled(); + }); + + it("wakes the thread from the snooze toast", async () => { + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const actions = useThreadActions(); + await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString()); + expect(add).toHaveBeenCalledWith( + expect.objectContaining({ title: expect.stringMatching(/^Snoozed until /) }), + ); + await undoOf(add, 0)(); + expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }); +}); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d9c3eccbeb4e..82348be3b6e4 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -226,6 +226,33 @@ describe("settle thread shortcut", () => { }); }); +describe("thread undo shortcut", () => { + it("resolves mod+z with nothing editable focused", () => { + assert.equal( + resolveShortcutCommand(event({ key: "z", metaKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false, editableFocus: false }, + }), + "thread.undo", + ); + }); + + it("leaves native undo alone inside text fields and terminals", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Win32", + context: { editableFocus: true }, + }), + ); + assert.isNull( + resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Win32", + context: { terminalFocus: true }, + }), + ); + }); +}); + describe("copy thread reference shortcut", () => { it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { assert.equal( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 84d325c38d97..b615d3564437 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -36,6 +36,9 @@ export interface ShortcutMatchContext { previewOpen: boolean; isWeb: boolean; isDesktop: boolean; + /** A text field, textarea, select or rich-text editor owns the keyboard. + Optional: only chords that collide with native editing consult it. */ + editableFocus?: boolean; [key: string]: boolean; } @@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc previewOpen: false, isWeb: !isElectron, isDesktop: isElectron, + editableFocus: false, ...options?.context, }; } diff --git a/apps/web/src/lib/editableFocus.ts b/apps/web/src/lib/editableFocus.ts new file mode 100644 index 000000000000..ad704a39f616 --- /dev/null +++ b/apps/web/src/lib/editableFocus.ts @@ -0,0 +1,17 @@ +const EDITABLE_SELECTOR = [ + "input", + "textarea", + "select", + '[contenteditable=""]', + '[contenteditable="true"]', + '[contenteditable="plaintext-only"]', + '[role="textbox"]', +].join(","); + +/** + * Whether a text-editing element owns the keyboard. Shortcuts that share + * their chord with native editing (mod+z) must yield when this is true. + */ +export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean { + return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; +} diff --git a/apps/web/src/lib/favicon.test.ts b/apps/web/src/lib/favicon.test.ts deleted file mode 100644 index 8fe9e65eb4d2..000000000000 --- a/apps/web/src/lib/favicon.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { faviconUrlForOrigin } from "./favicon"; - -describe("faviconUrlForOrigin", () => { - it("never sends private origin hostnames to the public provider", () => { - for (const url of [ - "http://localhost:3000/", - "http://127.0.0.1:3000/", - "http://0.0.0.0:3000/", - "http://devbox:3000/", - "https://24x.xf.local/", - "http://printer.home.arpa/", - "http://192.168.1.20:3000/", - "http://[::]/", - "http://[::ffff:192.168.1.20]/", - "http://100.65.180.100:3000/", - "https://devbox.example.ts.net/", - "http://192.0.2.1/", - "http://198.51.100.1/", - "http://203.0.113.1/", - "http://224.0.0.1/", - "http://240.0.0.1/", - "http://[2001:db8::1]/", - "http://[ff02::1]/", - "http://app.test../", - "https://24x.xf.local../", - "http://printer.home.arpa../", - "https://devbox.example.ts.net../", - "http://127.0.0.1../", - "http://127.1../", - "http://10.1../", - "http://172.16.1../", - "http://192.168.1../", - ]) { - expect(faviconUrlForOrigin(url)).toBeNull(); - } - expect(faviconUrlForOrigin("https://example.com/path", 32)).toBe( - "https://www.google.com/s2/favicons?domain=example.com&sz=32", - ); - }); -}); diff --git a/docs/user/devices.md b/docs/user/devices.md index b388688bae29..02e06d2bdffa 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds, installation, and connectivity to development servers such as Metro separately. A simulator on another machine cannot reach Metro through your environment's localhost without forwarding or another reachable address. + +## Device tool updates + +The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings → Integrations → Check device tool versions reads installed versions without installing tools or starting devices. + +To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 9c2f92a3e336..78c792d29851 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -84,9 +84,10 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`. ## When conditions Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`, -`previewOpen`, `modelPickerOpen`, `isWeb`, and `isDesktop`. `isWeb` is true in a -browser tab. `isDesktop` is true in the desktop app. Unknown keys evaluate to -`false`. +`previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`. +`editableFocus` is true while a text field, the composer, or another editor has +the keyboard. `isWeb` is true in a browser tab. `isDesktop` is true in the +desktop app. Unknown keys evaluate to `false`. `mod+1` through `mod+9` jump to the first nine threads, and to models while the model picker is open. Those defaults use `isDesktop` so they do not steal the @@ -110,6 +111,11 @@ a shortcut. `thread.stop` interrupts the running turn in the focused thread. It has no default shortcut; assign one in **Settings → Keybindings**. +`thread.undo` (`mod+z` by default) reverses the most recent thread action that is +still offering **Undo** in a notification, such as an unpin, settle, snooze, or +archive. Its default rule skips text fields and terminals so native undo keeps +working there. + `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your [new-thread defaults](./thread-sidebar.md#start-a-thread). diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 650954d7a586..3d3215a193f6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -29,6 +29,12 @@ and worktree while you stay in the new thread composer. This requires a Git proj Pin a thread from its menu to keep it above your active work. +On web and desktop, unpinning, settling, snoozing, and archiving a thread each show +a notification with **Undo** for five seconds. Undo restores the thread's previous +state, including its pinned position, and reopens an archived thread you were +viewing. `mod+z` triggers the most recent Undo when no text field is focused; see +[Keybindings](./keybindings.md#commands-with-special-behavior). + On web and desktop, you can also drag files from your computer onto any thread row: the thread opens and the files are attached in its composer, ready for your next message. The same per-message file limits apply as when attaching diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts index 1e6523497ef3..a9f5d5bb2ebd 100644 --- a/packages/client-runtime/src/state/device.ts +++ b/packages/client-runtime/src/state/device.ts @@ -1,4 +1,4 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -70,3 +70,30 @@ export function createDeviceEnvironmentAtoms( }), }; } + +/** Unknown inventory is distinct from a completed check that found no install. */ +export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { + if (!tools) return ["Device tool versions have not been checked."]; + return ( + [ + ["Device hub", tools.hub], + ["Agent tools", tools.agent], + ] as const + ).map(([name, tool]) => { + const installed = tool.installedVersions.length ? tool.installedVersions.join(", ") : "none"; + return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; + }); +} + +export function deviceToolUpdatePolicy(tools: DeviceToolVersions | undefined) { + if (!tools) return "Versions have not been checked. Reconnect the host and check versions."; + const outdated = [tools.hub, tools.agent].filter( + (tool) => + tool.installedVersions.length > 0 && !tool.installedVersions.includes(tool.requiredVersion), + ); + return outdated.length > 0 + ? "Update pending. Required tools will install automatically when next used. The host needs network access; an older install is not used as a fallback." + : "Required tools are installed automatically when needed. Checking versions does not install or start anything."; +} +export const deviceToolUpdateOwnership = + "This environment's T3 server chooses device tool versions for itself and its SSH hosts. Update that server to receive newer tool versions; updating only your browser or mobile app does not update a remote server."; diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts new file mode 100644 index 000000000000..11587fa5996e --- /dev/null +++ b/packages/contracts/src/device.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; +import { deviceToolInstallMessage } from "./device.ts"; + +describe("device tool install progress", () => { + it("distinguishes a new install from an upgrade and chooses versions numerically", () => { + expect( + deviceToolInstallMessage("device hub", { + requiredVersion: "0.11.0", + installedVersions: [], + runningVersion: null, + }), + ).toBe("Installing device hub 0.11.0…"); + expect( + deviceToolInstallMessage("device hub", { + requiredVersion: "0.11.0", + installedVersions: ["0.9.0", "0.10.0"], + runningVersion: null, + }), + ).toBe("Updating device hub from 0.10.0 to 0.11.0…"); + }); +}); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index a93198a1e639..63859cfd3619 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -74,11 +74,26 @@ export const DevicePlatformAvailability = Schema.Struct({ }); export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; +export const DeviceToolVersion = Schema.Struct({ + requiredVersion: Schema.String, + installedVersions: Schema.Array(Schema.String), + runningVersion: Schema.NullOr(Schema.String), +}); +export type DeviceToolVersion = typeof DeviceToolVersion.Type; + +export const DeviceToolVersions = Schema.Struct({ + hub: DeviceToolVersion, + agent: DeviceToolVersion, +}); +export type DeviceToolVersions = typeof DeviceToolVersions.Type; + export const DeviceHostSummary = Schema.Struct({ id: DeviceHostId, kind: Schema.Literals(["local", "ssh"]), label: TrimmedNonEmptyString, platforms: Schema.Array(DevicePlatformAvailability), + tools: Schema.optional(DeviceToolVersions), + toolInspectionError: Schema.optional(Schema.String), hubInstalled: Schema.Boolean, agentDeviceInstalled: Schema.Boolean, }); @@ -113,6 +128,9 @@ export const DeviceSession = Schema.Struct({ export type DeviceSession = typeof DeviceSession.Type; export const DeviceServiceState = Schema.Struct({ + supportsHostRetry: Schema.optional(Schema.Boolean), + supportsToolUpdate: Schema.optional(Schema.Boolean), + supportsToolInspection: Schema.optional(Schema.Boolean), hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), @@ -136,7 +154,14 @@ export const DeviceServiceState = Schema.Struct({ }); export type DeviceServiceState = typeof DeviceServiceState.Type; -export const DeviceListInput = Schema.Struct({}); +export const DeviceListInput = Schema.Struct({ + /** Install this server's pinned tool without enabling access or starting helpers. */ + updateTool: Schema.optional(Schema.Literals(["hub", "agent"])), + /** Read inventory without installing tools or starting helpers. */ + inspectOnly: Schema.optional(Schema.Boolean), + /** Retry this host only, including agent tools if access was already granted. */ + retryHostId: Schema.optional(DeviceHostId), +}); export type DeviceListInput = typeof DeviceListInput.Type; export const DeviceConfigureInput = Schema.Struct({ @@ -546,3 +571,13 @@ export const DeviceToolError = Schema.Union([ DeviceActionUnavailableError, ]); export type DeviceToolError = typeof DeviceToolError.Type; + +export function deviceToolInstallMessage(name: string, tool: DeviceToolVersion | undefined) { + if (!tool) return `Installing ${name}…`; + const previous = + tool.runningVersion ?? + [...tool.installedVersions].sort((a, b) => a.localeCompare(b, "en", { numeric: true })).at(-1); + return previous && !tool.installedVersions.includes(tool.requiredVersion) + ? `Updating ${name} from ${previous} to ${tool.requiredVersion}…` + : `Installing ${name} ${tool.requiredVersion}…`; +} diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 44d76ded1703..5093ba9a25ad 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [ "thread.copyReference", "thread.settle", "thread.pin", + "thread.undo", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index d76d4c248a97..ee159b6838a3 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, + { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0daed3ff962..6b4d8281b7df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,6 +637,9 @@ importers: '@tiptap/core': specifier: ^3.31.3 version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))