diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index db3a2759e3ac5..f93a2f1ce9c9c 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -46,6 +46,7 @@ import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import { continueRestartedRun } from "./RestartContinuation.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; @@ -55,7 +56,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; const layerPlatformTest = Layer.merge( - Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, PortScanner.layerTest), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link"), }), diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index b27b6604b1435..0f3e62022581e 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -23,6 +23,7 @@ import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.t import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -117,7 +118,7 @@ const unusedEnrichment = { const layerRuntime = (dbPath: string) => { const layerPlatform = Layer.merge( - Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, PortScanner.layerTest), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused"), }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 896de803a38f6..763c767b9e850 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { AgentScope } from "@t3tools/shared/AgentScope"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { EnvironmentId, type ModelSelection, @@ -31,7 +32,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { HttpServer } from "effect/http"; +import { FetchHttpClient, HttpServer } from "effect/http"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -41,6 +42,8 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; +import * as ProcessRunner from "../processRunner.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; @@ -604,6 +607,7 @@ function layerTest(input: { readonly scopeCloseReached?: Deferred.Deferred; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; + readonly portDiscoveryLayer?: Layer.Layer; }) { const layerConfiguredEventSink = input.flakyReleaseWrites !== undefined @@ -686,6 +690,7 @@ function layerTest(input: { layerProviderEventIngestorTest, layerConfiguredMcpRegistry, layerTestStores, + input.portDiscoveryLayer ?? PortScanner.layerTest, ...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]), ...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]), ), @@ -1378,6 +1383,14 @@ it.effect( // Like OpenCode2, a stopped start can still emit turn.terminal later. const holdStart = yield* Ref.make(false); const startHeld = yield* Deferred.make(); + const discoveryLeases = new Set(); + const portDiscoveryLayer = Layer.mock(PortScanner.PortDiscovery)({ + trackAgent: () => { + const lease = Symbol(); + discoveryLeases.add(lease); + return () => void discoveryLeases.delete(lease); + }, + }); yield* Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -1440,11 +1453,13 @@ it.effect( }); yield* startTurn(threadB); + assert.equal(discoveryLeases.size, 1); yield* Ref.set(holdStart, true); const startingA = yield* startTurn(threadA).pipe(Effect.forkChild); yield* Deferred.await(startHeld); yield* Fiber.interrupt(startingA); + assert.equal(discoveryLeases.size, 1); const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); assert.isDefined(queue); @@ -1466,11 +1481,15 @@ it.effect( yield* Effect.yieldNow; assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); assert.equal((yield* Ref.get(state)).closeCount, 0); + assert.equal(discoveryLeases.size, 1); + yield* manager.close(providerSessionId); + assert.equal(discoveryLeases.size, 0); }).pipe( Effect.provide( layerTest({ state, idleTimeoutMs: 1000, + portDiscoveryLayer, startTurn: Ref.get(holdStart).pipe( Effect.flatMap((hold) => hold @@ -1484,6 +1503,203 @@ it.effect( }), ); +const openDiscoveryTurn = Effect.fnUntraced(function* (threadId: ThreadId) { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const open = manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const runtime = yield* open; + const providerThread = makeProviderThread({ idAllocator, threadId, providerSessionId, now }); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const input = { + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "start a server", + attachments: [], + }, + modelSelection, + runtimePolicy, + } satisfies Parameters[0]; + const terminal = { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: providerThread.id, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "discovery-turn", + }), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + } satisfies ProviderAdapter.ProviderAdapterV2Event; + return { manager, providerSessionId, runtime, open, input, terminal }; +}); + +it.effect("ProviderSessionManagerV2 ignores idle signals from a replaced runtime", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const startHeld = yield* Deferred.make(); + const finishStart = yield* Deferred.make(); + const holdStart = yield* Ref.make(true); + const discoveryLeases = new Set(); + const portDiscoveryLayer = Layer.mock(PortScanner.PortDiscovery)({ + trackAgent: () => { + const lease = Symbol(); + discoveryLeases.add(lease); + return () => void discoveryLeases.delete(lease); + }, + }); + yield* Effect.gen(function* () { + const { manager, providerSessionId, runtime, open, input, terminal } = + yield* openDiscoveryTurn(ThreadId.make("discovery-replaced-runtime")); + const starting = yield* runtime.startTurn(input).pipe(Effect.forkChild); + yield* Deferred.await(startHeld); + assert.equal(discoveryLeases.size, 1); + const oldQueue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId))!; + yield* manager.close(providerSessionId); + assert.equal(discoveryLeases.size, 0); + const replacement = yield* open; + yield* replacement.startTurn(input); + assert.equal(discoveryLeases.size, 1); + + yield* Deferred.succeed(finishStart, undefined); + assert.isTrue(Exit.isFailure(yield* Fiber.await(starting))); + assert.equal(discoveryLeases.size, 1); + + const staleSubscription = yield* runtime.subscribeEvents!; + const lateEvent = yield* staleSubscription.events.pipe(Stream.runHead, Effect.forkChild); + yield* Queue.offer(oldQueue, terminal); + assert.deepStrictEqual(yield* Fiber.join(lateEvent), Option.some(terminal)); + assert.equal(discoveryLeases.size, 1); + yield* manager.close(providerSessionId); + assert.equal(discoveryLeases.size, 0); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + portDiscoveryLayer, + startTurn: Effect.gen(function* () { + if (!(yield* Ref.getAndSet(holdStart, false))) return; + yield* Deferred.succeed(startHeld, undefined); + yield* Deferred.await(finishStart); + return yield* Effect.die("The replaced runtime failed its start."); + }), + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 discovers listeners started after a turn ends", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const pending = yield* Ref.make(true); + const threadId = ThreadId.make("discovery-background-work"); + let listeners = ""; + let scans = 0; + const portDiscoveryLayer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HostProcess.Platform, "linux"), + FileSystem.layerNoop({ + readFileString: () => Effect.succeed(`${PortScanner.AGENT_THREAD_ENV}=${threadId}\0`), + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.sync(() => { + scans += 1; + return { + stdout: listeners, + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }), + FetchHttpClient.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const { manager, providerSessionId, runtime, input, terminal } = + yield* openDiscoveryTurn(threadId); + yield* runtime.startTurn(input); + const subscription = yield* runtime.subscribeEvents!; + const observed = yield* Queue.unbounded(); + yield* subscription.events.pipe( + Stream.runForEach((event) => Queue.offer(observed, event)), + Effect.forkChild, + ); + const events = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId))!; + yield* Queue.offer(events, terminal); + assert.deepStrictEqual(yield* Queue.take(observed), terminal); + assert.deepStrictEqual(yield* scanner.scan(), []); + + const backgroundEvent = { + type: "background_work.updated", + driver: CODEX_DRIVER, + providerTurnId: terminal.providerTurnId, + pending: true, + } satisfies ProviderAdapter.ProviderAdapterV2Event; + yield* Queue.offer(events, backgroundEvent); + yield* Queue.take(observed); + listeners = "p4242\ncnode\nn127.0.0.1:43171\n"; + assert.deepStrictEqual( + (yield* scanner.scan()).map((server) => ({ port: server.port, agent: server.agent })), + [{ port: 43171, agent: { threadId } }], + ); + + yield* Ref.set(pending, false); + yield* Queue.offer(events, { ...backgroundEvent, pending: false }); + yield* Queue.take(observed); + listeners = ""; + assert.deepStrictEqual(yield* scanner.scan(), []); + const scansAfterWork = scans; + assert.deepStrictEqual(yield* scanner.scan(), []); + assert.equal(scans, scansAfterWork); + yield* manager.close(providerSessionId); + }).pipe( + Effect.provide( + Layer.mergeAll( + portDiscoveryLayer, + layerTest({ + state, + idleTimeoutMs: 60_000, + portDiscoveryLayer, + hasPendingBackgroundWork: Ref.get(pending), + hasPendingBackgroundWorkForThread: Ref.get(pending), + }), + ), + ), + ); + }), +); + it.effect("ProviderSessionManagerV2 gives up on a provider that never confirms a Stop", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 39f383666bae1..38f17744d15fa 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -42,6 +42,7 @@ import { } from "../observability/Metrics.ts"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; @@ -230,6 +231,7 @@ interface LiveSessionEntry { readonly scope: Scope.Closeable; readonly idleGeneration: number; readonly busyTurns: ReadonlySet; + readonly releasePortDiscovery: (() => void) | null; readonly idleFiber: Fiber.Fiber | null; readonly pinnedSinceMs: number | null; readonly idleThreadUnloads: ReadonlyMap; @@ -329,11 +331,13 @@ export const layerWithOptions = ( | ProjectionStore.ProjectionStoreV2 | ProviderEventIngestor.ProviderEventIngestorV2 | ProviderAdapterRegistry.ProviderAdapterRegistryV2 + | PortScanner.PortDiscovery > => Layer.effect( ProviderSessionManagerV2, Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; + const portDiscovery = yield* PortScanner.PortDiscovery; const fileSystem = yield* FileSystem.FileSystem; const mcpSessionRegistry = yield* McpSessionRegistry.McpSessionRegistry; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; @@ -966,6 +970,7 @@ export const layerWithOptions = ( } const updated = new Map(current); updated.delete(key); + existing.releasePortDiscovery?.(); return ["removed", updated] as const; }), DateTime.now, @@ -1451,6 +1456,7 @@ export const layerWithOptions = ( updated.set(key, { ...entry, busyTurns: new Set(entry.busyTurns).add(turnKey), + releasePortDiscovery: entry.releasePortDiscovery ?? portDiscovery.trackAgent(), idleFiber: null, pinnedSinceMs: null, }); @@ -1462,19 +1468,48 @@ export const layerWithOptions = ( }), ); + // Rechecked on provider events, including streaming updates; avoid a span per event. + const releaseIdlePortDiscovery = Effect.fnUntraced(function* ( + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, + ) { + const key = sessionKey(runtime.providerSessionId); + const entry = (yield* Ref.get(sessions)).get(key); + if ( + entry?.runtime !== runtime || + entry.busyTurns.size > 0 || + entry.releasePortDiscovery === null + ) { + return; + } + const pending = + runtime.hasPendingBackgroundWork === undefined + ? false + : yield* runtime.hasPendingBackgroundWork.pipe( + Effect.catchCause(() => Effect.succeed(true)), + ); + if (pending) return; + yield* Ref.update(sessions, (current) => { + const latest = current.get(key); + if (latest?.runtime !== runtime || latest.busyTurns !== entry.busyTurns) return current; + latest.releasePortDiscovery?.(); + return new Map(current).set(key, { ...latest, releasePortDiscovery: null }); + }); + }); + const markIdle = ( providerSessionId: ProviderSessionId, providerThreadId: ProviderThreadId, runOrdinal: number, + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, ) => withActivityError( providerSessionId, Effect.gen(function* () { const key = sessionKey(providerSessionId); - yield* Ref.update(sessions, (current) => { + const marked = yield* Ref.modify(sessions, (current) => { const entry = current.get(key); - if (entry === undefined) { - return current; + if (entry === undefined || entry.runtime !== runtime) { + return [false, current] as const; } const busyTurns = new Set(entry.busyTurns); busyTurns.delete(busyTurnKey(providerThreadId, runOrdinal)); @@ -1483,8 +1518,10 @@ export const layerWithOptions = ( ...entry, busyTurns, }); - return updated; + return [true, updated] as const; }); + if (!marked) return; + yield* releaseIdlePortDiscovery(runtime); yield* scheduleIdleReleaseInternal(providerSessionId); yield* scheduleThreadUnload(providerSessionId, providerThreadId); }), @@ -1881,7 +1918,12 @@ export const layerWithOptions = ( Exit.isFailure(exit) ? observeActivity( providerSessionId, - markIdle(providerSessionId, input.providerThread.id, input.runOrdinal), + markIdle( + providerSessionId, + input.providerThread.id, + input.runOrdinal, + runtime, + ), ) : Effect.void, ), @@ -1991,10 +2033,13 @@ export const layerWithOptions = ( entry.runtime.providerSessionId, event.providerThreadId, event.runOrdinal, + entry.runtime, ) : metadataOnly ? Effect.void - : touchActivity(entry.runtime.providerSessionId, { providerActivity: true }), + : touchActivity(entry.runtime.providerSessionId, { providerActivity: true }).pipe( + Effect.andThen(releaseIdlePortDiscovery(entry.runtime)), + ), ).pipe( Effect.andThen( event.type === "provider_session.updated" @@ -2272,6 +2317,7 @@ export const layerWithOptions = ( scope: sessionScope, idleGeneration: 0, busyTurns: new Set(), + releasePortDiscovery: null, idleFiber: null, pinnedSinceMs: null, idleThreadUnloads: new Map(), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b90f491dfa94e..4b45907d3c1cb 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -87,6 +87,7 @@ import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; import * as ThreadLaunchService from "./ThreadLaunchService.ts"; @@ -94,7 +95,7 @@ import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const layerPlatformTest = Layer.merge( - Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, PortScanner.layerTest), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link"), }), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index ed51ee642418c..664d171c81876 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -21,6 +21,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../../config.ts"; import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import * as ServerSettings from "../../serverSettings.ts"; +import * as PortScanner from "../../preview/PortScanner.ts"; import * as ManagedPolicy from "../../policy/ManagedPolicy.ts"; import * as ThreadManagementService from "../ThreadManagementService.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -542,6 +543,7 @@ export function layerWithRegistry( layerCommandOutputHubProvided, ).pipe( Layer.provide(ProviderTurnStartServiceTestkit.layer), + Layer.provide(PortScanner.layerTest), Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d408d6c8d5d94..a6c9165f5e997 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -480,6 +480,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder ? ["Project order"] : []), + ...(settings.sidebarShowPorts !== DEFAULT_UNIFIED_SETTINGS.sidebarShowPorts + ? ["Show thread ports"] + : []), ...(settings.sidebarWorkingShelfEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? ["Working section"] : []), @@ -651,6 +654,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, + sidebarShowPorts: DEFAULT_UNIFIED_SETTINGS.sidebarShowPorts, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests, @@ -882,6 +886,27 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ sidebarShowPorts: DEFAULT_UNIFIED_SETTINGS.sidebarShowPorts }) + } + /> + ) : null + } + control={ + updateSettings({ sidebarShowPorts: Boolean(checked) })} + aria-label="Show thread ports" + /> + } + /> ({ + useThreadDiscoveredPorts: () => [{ port: 3000 }, { port: 4000 }, { port: 5000 }], +})); + +it("announces running ports when navigating a search result with an explicit name", async () => { + const initial = getClientSettings(); + using _settings = { [Symbol.dispose]: () => __setClientSettingsForTests(initial) }; + __setClientSettingsForTests({ ...initial, sidebarShowPorts: true }); + await using screen = await render( +
    + +
, + ); + + await expect + .element(screen.getByRole("option", { name: "Dev server" })) + .toHaveAccessibleDescription("Running ports: 3000, 4000, 5000"); +}); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index f0c4519877437..faf2d61c9aaf8 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,3 +1,5 @@ +import { useClientSettings } from "../../hooks/useSettings"; +import { useThreadDiscoveredPorts } from "../../portDiscoveryState"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; @@ -40,6 +42,7 @@ import { ClockIcon, GitBranchIcon, MessageCircleQuestionIcon, + NetworkIcon, PinIcon, PinOffIcon, ShieldQuestionIcon, @@ -51,6 +54,7 @@ import { } from "lucide-react"; import { memo, + useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -129,6 +133,44 @@ export type SidebarProviderEntry = Pick< | "acpRegistryIconUrl" | "models" >; +function ThreadPorts({ + thread, + descriptionId, +}: { + thread: SidebarThreadSummary; + descriptionId: string; +}) { + const enabled = useClientSettings((settings) => settings.sidebarShowPorts); + const servers = useThreadDiscoveredPorts({ + environmentId: enabled ? thread.environmentId : null, + threadId: thread.id, + }); + const ports = [...new Set(servers.map((server) => server.port))].sort((a, b) => a - b); + if (ports.length === 0) return null; + const description = `Running ports: ${ports.join(", ")}`; + return ( + <> + + {description} + + + + {description} + + + ); +} + function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; return label.endsWith(" ago") ? label.slice(0, -4) : label; @@ -653,6 +695,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP variant, variantAction, } = props; + const portsDescriptionId = useId(); const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); @@ -1218,6 +1261,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP data-sidebar-row={threadKey} data-sidebar-parent={props.navigationParent} aria-label={accessibility.label} + aria-describedby={portsDescriptionId} aria-current={accessibility.current} data-testid={inFolder ? "sidebar-row-folder" : "sidebar-row-slim"} aria-busy={isRegeneratingTitle || undefined} @@ -1254,6 +1298,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {draftIndicator} {title} + {multiline ? titleLineIcons : null} {pinIndicator} {multiline ? null : terminalStatusIcon} @@ -1414,6 +1459,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP data-sidebar-row={threadKey} data-sidebar-parent={props.navigationParent} aria-label={accessibility.label} + aria-describedby={portsDescriptionId} aria-current={accessibility.current} data-testid="sidebar-row-card" aria-busy={isRegeneratingTitle || undefined} @@ -1579,6 +1625,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{title} + {isRegeneratingTitle ? ( Regenerating title @@ -1636,6 +1683,7 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props projectDisplayName: props.projectDisplayName, isActive: props.isRouteActive, }); + const portsDescriptionId = useId(); const threadRef = scopeThreadRef(thread.environmentId, thread.id); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( props.isHighlighted || props.isRouteActive, @@ -1702,7 +1750,7 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props tabIndex={-1} aria-selected={props.isHighlighted} aria-current={accessibility.current} - aria-describedby={`${props.resultId}-pr`} + aria-describedby={`${props.resultId}-pr ${portsDescriptionId}`} aria-label={accessibility.label} onMouseMove={props.onHighlight} onClick={props.onSelect} @@ -1743,6 +1791,7 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props > {thread.title} +