From 9aa68085a0459b247fc72105267604bde28183fb Mon Sep 17 00:00:00 2001 From: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:30:34 -0600 Subject: [PATCH 1/5] feat: add remote folder browsing and harden provider turns Support picking server-side workspaces from remote web sessions and recover more cleanly when Codex turn startup, steering, or interruption state goes stale. --- AGENTS.md | 1 + .../TestProviderAdapter.integration.ts | 4 + apps/server/src/codexAppServerManager.test.ts | 6 + apps/server/src/codexAppServerManager.ts | 55 ++++- .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 119 +++++++++++ .../Layers/ProjectionPipeline.ts | 5 + .../Layers/ProviderCommandReactor.test.ts | 179 ++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 187 ++++++++++++++++- .../Layers/ProviderRuntimeIngestion.test.ts | 40 ++++ .../Layers/ProviderRuntimeIngestion.ts | 16 +- apps/server/src/orchestration/decider.ts | 45 ++++ .../src/provider/Layers/ClaudeAdapter.ts | 10 + .../src/provider/Layers/CodexAdapter.test.ts | 9 + .../src/provider/Layers/CodexAdapter.ts | 106 ++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 10 + .../src/provider/Layers/GeminiAdapter.ts | 9 + .../src/provider/Layers/OpenClawAdapter.ts | 10 + .../Layers/ProviderAdapterRegistry.test.ts | 4 + .../src/provider/Layers/ProviderHealth.ts | 120 +++++++++++ .../provider/Layers/ProviderService.test.ts | 13 ++ .../src/provider/Layers/ProviderService.ts | 22 ++ .../src/provider/Services/ProviderAdapter.ts | 6 + .../src/provider/Services/ProviderService.ts | 6 + .../src/sme/Layers/SmeChatServiceLive.test.ts | 1 + apps/server/src/workspaceEntries.ts | 75 +++++++ apps/server/src/wsServer.test.ts | 1 + apps/server/src/wsServer.ts | 12 +- apps/web/src/components/ChatView.tsx | 172 ++++++++++++---- .../src/components/CloneRepositoryDialog.tsx | 42 +++- .../components/RemoteFolderPickerDialog.tsx | 193 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 58 +++++- .../src/components/chat/MessagesTimeline.tsx | 9 + .../components/home/ChatHomeEmptyState.tsx | 140 ++++++++----- .../onboarding/useOnboardingState.ts | 8 +- apps/web/src/lib/projectReactQuery.ts | 7 +- apps/web/src/lib/remoteFolderPicker.ts | 100 +++++++++ apps/web/src/types.ts | 2 + bun.lock | 11 +- packages/contracts/src/orchestration.ts | 49 +++++ packages/contracts/src/project.ts | 1 + packages/contracts/src/provider.ts | 9 + 42 files changed, 1744 insertions(+), 129 deletions(-) create mode 100644 apps/web/src/components/RemoteFolderPickerDialog.tsx create mode 100644 apps/web/src/lib/remoteFolderPicker.ts diff --git a/AGENTS.md b/AGENTS.md index a5dbc8e56..8801ebde7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. - NEVER run `bun test`. Always use `bun run test` (runs Vitest). +- For any change that must be visible on the served app endpoint, rebuild and restart the served app before considering the task complete. A browser hard refresh is not sufficient for the Tailscale-served production bundle. ## Project Snapshot diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 7e0c8288d..781cb78cf 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -396,6 +396,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter }) : missingSessionEffect(provider, threadId); + const steerTurn: ProviderAdapterShape["steerTurn"] = (input) => + sessions.has(input.threadId) ? Effect.void : missingSessionEffect(provider, input.threadId); + const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( threadId, requestId, @@ -479,6 +482,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter }, startSession, sendTurn, + steerTurn, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index bb6b6429f..e32676b3c 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -256,6 +256,12 @@ describe("isRecoverableThreadResumeError", () => { ), ).toBe(false); }); + + it("treats thread-resume timeouts as recoverable", () => { + expect(isRecoverableThreadResumeError(new Error("Timed out waiting for thread/resume."))).toBe( + true, + ); + }); }); describe("readCodexAccountSnapshot", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index da1f3a706..afe254285 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -129,6 +129,12 @@ export interface CodexAppServerSendTurnInput { readonly interactionMode?: ProviderInteractionMode; } +export interface CodexAppServerSteerTurnInput { + readonly threadId: ThreadId; + readonly input: string; + readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; +} + export interface CodexAppServerStartSessionInput { readonly threadId: ThreadId; readonly provider?: "codex"; @@ -170,6 +176,7 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "no such thread", "unknown thread", "does not exist", + "timed out waiting for thread/resume", ]; const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; @@ -394,9 +401,9 @@ export function resolveCodexModelForAccount( } /** - * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` - * wrapper, leaving the actual command running. Use `taskkill /T` to kill the - * entire process tree instead. + * Codex can spawn nested helper processes. On Unix we run each session in its + * own process group so teardown can kill the entire subtree instead of leaking + * orphaned `@github/copilot` workers after restarts and timeouts. */ function killChildTree(child: ChildProcessWithoutNullStreams): void { if (process.platform === "win32" && child.pid !== undefined) { @@ -407,7 +414,15 @@ function killChildTree(child: ChildProcessWithoutNullStreams): void { // fallback to direct kill } } - child.kill(); + if (child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + return; + } catch { + // fallback to direct kill + } + } + child.kill("SIGKILL"); } export function normalizeCodexModelSlug( @@ -599,6 +614,7 @@ export class CodexAppServerManager extends EventEmitter { + const context = this.requireSession(input.threadId); + const providerThreadId = readResumeThreadId({ + threadId: context.session.threadId, + runtimeMode: context.session.runtimeMode, + resumeCursor: context.session.resumeCursor, + }); + const activeTurnId = context.session.activeTurnId; + if (!providerThreadId || !activeTurnId) { + throw new Error("Cannot steer without an active provider turn."); + } + + const steerInput: Array<{ type: "text"; text: string } | { type: "image"; url: string }> = []; + for (const attachment of input.attachments ?? []) { + steerInput.push({ + type: "image", + url: attachment.url, + }); + } + steerInput.push({ + type: "text", + text: input.input, + }); + + await this.sendRequest(context, "turn/steer", { + threadId: providerThreadId, + expectedTurnId: activeTurnId, + input: steerInput, + }); + } + async readThread(threadId: ThreadId): Promise { const context = this.requireSession(threadId); const providerThreadId = readResumeThreadId({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 6ed0d6228..2143d3eba 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -87,6 +87,7 @@ function createProviderServiceHarness( const service: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), + steerTurn: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index b37d9a953..bef4457c8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1927,6 +1927,125 @@ it.effect("restores pending turn-start metadata across projection pipeline resta ), ); +it.effect("clears pending turn-start rows when provider turn start fails", () => + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + const threadId = ThreadId.makeUnsafe("thread-start-failure"); + const messageId = MessageId.makeUnsafe("message-start-failure"); + const now = "2026-02-26T15:00:00.000Z"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-start-failure-project"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-start-failure"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-project"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-project"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-start-failure"), + title: "Project Start Failure", + workspaceRoot: "/tmp/project-start-failure", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-start-failure-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-thread"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-start-failure"), + title: "Thread Start Failure", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-start-requested", + eventId: EventId.makeUnsafe("evt-start-failure-requested"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-requested"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-requested"), + metadata: {}, + payload: { + threadId, + messageId, + runtimeMode: "full-access", + createdAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.makeUnsafe("evt-start-failure-activity"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-activity"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-activity"), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.makeUnsafe("activity-start-failure"), + tone: "error", + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + payload: { + detail: "Timed out waiting for thread/start.", + }, + turnId: null, + createdAt: now, + }, + }, + }); + + const pendingRows = yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NULL + AND state = 'pending' + `; + + assert.deepEqual(pendingRows, []); + }).pipe( + Effect.provide( + makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-start-failure-"), + ), + ), +); + const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fd679574f..d3f2ff780 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -768,6 +768,11 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { : {}), createdAt: event.payload.activity.createdAt, }); + if (event.payload.activity.kind === "provider.turn.start.failed") { + yield* projectionTurnRepository.deletePendingTurnStartByThreadId({ + threadId: event.payload.threadId, + }); + } return; case "thread.reverted": { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2cde405ad..9c47ead0f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -169,6 +169,7 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); + const steerTurn = vi.fn((_: unknown) => Effect.void); const interruptTurn = vi.fn((_: unknown) => Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); @@ -211,6 +212,7 @@ describe("ProviderCommandReactor", () => { const service: ProviderServiceShape = { startSession: startSession as ProviderServiceShape["startSession"], sendTurn: sendTurn as ProviderServiceShape["sendTurn"], + steerTurn: steerTurn as ProviderServiceShape["steerTurn"], interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], @@ -281,6 +283,7 @@ describe("ProviderCommandReactor", () => { engine, startSession, sendTurn, + steerTurn, interruptTurn, respondToRequest, respondToUserInput, @@ -1108,6 +1111,84 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("full-access"); }); + it("stops and clears a session when turn start fails while the thread is stuck starting", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-bind-session"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-bind-session"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-starting-after-bind"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail(new Error("simulated start failure")) as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-fail-while-starting"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-fail-while-starting"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return thread?.session?.status === "stopped"; + }); + + expect(harness.stopSession.mock.calls.length).toBe(1); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.activeTurnId).toBeNull(); + expect(thread?.session?.lastError).toContain("simulated start failure"); + }); + it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1146,6 +1227,104 @@ describe("ProviderCommandReactor", () => { }); }); + it("clears stale active turns when interrupt is requested without a live provider turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-stale-interrupt"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-stale"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-stale"), + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.session + ?.activeTurnId === null + ); + }); + + expect(harness.interruptTurn.mock.calls.length).toBe(0); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + + it("falls back to sendTurn when steer is requested without a live provider turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-stale-steer"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-stale"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.steer", + commandId: CommandId.makeUnsafe("cmd-turn-steer-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("msg-steer-stale"), + role: "user", + text: "resume with fresh turn", + attachments: [], + }, + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.steerTurn.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: "thread-1", + input: "resume with fresh turn", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a6f92c74c..23215ee33 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -46,6 +46,7 @@ type ProviderIntentEvent = Extract< | "thread.runtime-mode-set" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" + | "thread.turn-steer-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" | "thread.session-stop-requested"; @@ -88,6 +89,10 @@ function resolveThreadModelSelection(thread: { const turnStartKeyForEvent = (event: ProviderIntentEvent): string => event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; +const shouldClearSessionAfterTurnStartFailure = ( + session: OrchestrationSession | null | undefined, +): boolean => session?.status === "starting"; + const serverCommandId = (tag: string): CommandId => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -265,11 +270,73 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); + const clearFailedTurnStartSession = (input: { + readonly threadId: ThreadId; + readonly detail: string; + readonly createdAt: string; + }) => + Effect.gen(function* () { + const thread = yield* resolveThread(input.threadId); + if (!shouldClearSessionAfterTurnStartFailure(thread?.session)) { + return; + } + + yield* providerService + .stopSession({ threadId: input.threadId }) + .pipe(Effect.orElseSucceed(() => undefined)); + + const refreshedThread = yield* resolveThread(input.threadId); + if (!refreshedThread?.session) { + return; + } + + yield* setThreadSession({ + threadId: input.threadId, + session: { + threadId: input.threadId, + status: "stopped", + providerName: refreshedThread.session.providerName, + runtimeMode: refreshedThread.session.runtimeMode, + activeTurnId: null, + lastError: input.detail, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); return readModel.threads.find((entry) => entry.id === threadId); }); + const resolveLiveSession = (threadId: ThreadId) => + providerService + .listSessions() + .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); + + const clearThreadActiveTurn = (input: { + readonly threadId: ThreadId; + readonly createdAt: string; + readonly providerName: OrchestrationSession["providerName"]; + readonly runtimeMode: OrchestrationSession["runtimeMode"]; + readonly status: OrchestrationSession["status"]; + readonly lastError: string | null; + }) => + setThreadSession({ + threadId: input.threadId, + session: { + threadId: input.threadId, + status: input.status, + providerName: input.providerName, + runtimeMode: input.runtimeMode, + activeTurnId: null, + lastError: input.lastError, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + const resolveRequestedTurnProvider = (input: { readonly threadSelection: ModelSelection; readonly provider?: ProviderKind; @@ -682,16 +749,24 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.catchCause((cause) => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", - detail: Cause.pretty(cause), - turnId: null, - createdAt: event.payload.createdAt, - }), - ), + Effect.catchCause((cause) => { + const detail = Cause.pretty(cause); + return Effect.gen(function* () { + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + }); + yield* clearFailedTurnStartSession({ + threadId: event.payload.threadId, + detail, + createdAt: event.payload.createdAt, + }); + }); + }), ); }); @@ -714,10 +789,98 @@ const make = Effect.gen(function* () { }); } + const liveSession = yield* resolveLiveSession(event.payload.threadId); + if (!liveSession || liveSession.activeTurnId === undefined) { + yield* clearThreadActiveTurn({ + threadId: event.payload.threadId, + createdAt: event.payload.createdAt, + providerName: liveSession?.provider ?? thread.session?.providerName ?? null, + runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + status: mapProviderSessionStatusToOrchestrationStatus(liveSession?.status ?? "ready"), + lastError: liveSession?.lastError ?? null, + }); + return; + } + // Orchestration turn ids are not provider turn ids, so interrupt by session. yield* providerService.interruptTurn({ threadId: event.payload.threadId }); }); + const processTurnSteerRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession || thread.session?.providerName !== "codex") { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn steer failed", + detail: !hasSession + ? "No active provider session is bound to this thread." + : "Turn steering is currently only supported for Codex sessions.", + turnId: null, + createdAt: event.payload.createdAt, + }); + } + + const message = thread.messages.find((entry) => entry.id === event.payload.messageId); + if (!message || message.role !== "user") { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn steer failed", + detail: `User message '${event.payload.messageId}' was not found for turn steer request.`, + turnId: null, + createdAt: event.payload.createdAt, + }); + } + + const liveSession = yield* resolveLiveSession(event.payload.threadId); + if (!liveSession || liveSession.activeTurnId === undefined) { + yield* clearThreadActiveTurn({ + threadId: event.payload.threadId, + createdAt: event.payload.createdAt, + providerName: liveSession?.provider ?? thread.session?.providerName ?? null, + runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + status: mapProviderSessionStatusToOrchestrationStatus(liveSession?.status ?? "ready"), + lastError: liveSession?.lastError ?? null, + }); + yield* sendTurnForThread({ + threadId: event.payload.threadId, + messageText: message.text, + ...(event.payload.providerInput !== undefined + ? { providerInput: event.payload.providerInput } + : {}), + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + createdAt: event.payload.createdAt, + }); + return; + } + + yield* providerService + .steerTurn({ + threadId: event.payload.threadId, + input: toNonEmptyProviderInput(event.payload.providerInput ?? message.text) ?? message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + }) + .pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn steer failed", + detail: Cause.pretty(cause), + turnId: null, + createdAt: event.payload.createdAt, + }), + ), + ); + }); + const processApprovalResponseRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -860,6 +1023,9 @@ const make = Effect.gen(function* () { case "thread.turn-interrupt-requested": yield* processTurnInterruptRequested(event); return; + case "thread.turn-steer-requested": + yield* processTurnSteerRequested(event); + return; case "thread.approval-response-requested": yield* processApprovalResponseRequested(event); return; @@ -893,6 +1059,7 @@ const make = Effect.gen(function* () { event.type !== "thread.runtime-mode-set" && event.type !== "thread.turn-start-requested" && event.type !== "thread.turn-interrupt-requested" && + event.type !== "thread.turn-steer-requested" && event.type !== "thread.approval-response-requested" && event.type !== "thread.user-input-response-requested" && event.type !== "thread.session-stop-requested" diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index e006881a2..b4558dd66 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -69,6 +69,7 @@ function createProviderServiceHarness() { const service: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), + steerTurn: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), @@ -428,6 +429,45 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("clears a stale active turn when the provider session returns to ready", async () => { + const harness = await createHarness(); + const seededAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-seed-stale-active-turn"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.makeUnsafe("turn-stale"), + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }), + ); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-ready-clears-stale-turn"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "ready", + }, + }); + + await waitForThread( + harness.engine, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { const harness = await createHarness(); const seededAt = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 82d05c0d8..3eac23303 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -964,12 +964,6 @@ const make = Effect.gen(function* () { event.type === "turn.started" || event.type === "turn.completed" ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" - ? null - : activeTurnId; const status = (() => { switch (event.type) { case "session.state.changed": @@ -987,6 +981,16 @@ const make = Effect.gen(function* () { return activeTurnId !== null ? "running" : "ready"; } })(); + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" + ? null + : event.type === "session.state.changed" + ? status === "running" + ? activeTurnId + : null + : activeTurnId; const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2fa0bcf3d..4c15d23dd 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -506,6 +506,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.turn.steer": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const userMessageEvent: Omit = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + role: "user", + text: command.message.text, + attachments: command.message.attachments, + turnId: null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + const turnSteerRequestedEvent: Omit = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + causationEventId: userMessageEvent.eventId, + type: "thread.turn-steer-requested", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + ...(command.providerInput !== undefined ? { providerInput: command.providerInput } : {}), + createdAt: command.createdAt, + }, + }; + return [userMessageEvent, turnSteerRequestedEvent]; + } + case "thread.approval.respond": { yield* requireThread({ readModel, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c2c44933c..e69d7817f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3064,6 +3064,15 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }); }); + const steerTurn: ClaudeAdapterShape["steerTurn"] = () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: "Turn steering is not supported by Claude Agent.", + }), + ); + const readThread: ClaudeAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); @@ -3164,6 +3173,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }, startSession, sendTurn, + steerTurn, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index e2aa9280e..c0d496c1e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -20,6 +20,7 @@ import { CodexAppServerManager, type CodexAppServerStartSessionInput, type CodexAppServerSendTurnInput, + type CodexAppServerSteerTurnInput, } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; @@ -59,6 +60,10 @@ class FakeCodexManager extends CodexAppServerManager { async (_threadId: ThreadId, _turnId?: TurnId): Promise => undefined, ); + public steerTurnImpl = vi.fn( + async (_input: CodexAppServerSteerTurnInput): Promise => undefined, + ); + public readThreadImpl = vi.fn(async (_threadId: ThreadId) => ({ threadId: asThreadId("thread-1"), turns: [], @@ -99,6 +104,10 @@ class FakeCodexManager extends CodexAppServerManager { return this.interruptTurnImpl(threadId, turnId); } + override steerTurn(input: CodexAppServerSteerTurnInput): Promise { + return this.steerTurnImpl(input); + } + override readThread(threadId: ThreadId) { return this.readThreadImpl(threadId); } diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5dd5cada0..56274e380 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -36,6 +36,7 @@ import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.t import { CodexAppServerManager, type CodexAppServerStartSessionInput, + type CodexAppServerSteerTurnInput, } from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { @@ -1513,6 +1514,110 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), }); + const steerTurn: CodexAdapterShape["steerTurn"] = (input) => + Effect.gen(function* () { + const textFileAttachments: Array<{ + readonly attachment: Extract< + NonNullable[number]>, + { type: "file" } + >; + readonly text: string; + }> = []; + const codexAttachments = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + if (attachment.type === "file") { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* toRequestError( + input.threadId, + "turn/steer", + new Error(`Invalid attachment id '${attachment.id}'.`), + ); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); + const text = extractTextAttachmentContents({ + mimeType: attachment.mimeType, + fileName: attachment.name, + bytes, + }); + if (text === null) { + return yield* toRequestError( + input.threadId, + "turn/steer", + new Error( + `Unsupported file attachment '${attachment.name}'. Attach UTF-8 text files or images.`, + ), + ); + } + textFileAttachments.push({ attachment, text }); + return null; + } + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* toRequestError( + input.threadId, + "turn/steer", + new Error(`Invalid attachment id '${attachment.id}'.`), + ); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); + return { + type: "image" as const, + url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, + }; + }), + { concurrency: 1 }, + ).pipe( + Effect.map((attachments) => attachments.filter((attachment) => attachment !== null)), + ); + + const steerInputText = buildFileAttachmentContextText({ + baseText: input.input, + attachments: textFileAttachments, + }); + + return yield* Effect.tryPromise({ + try: () => { + const managerInput: CodexAppServerSteerTurnInput = { + threadId: input.threadId, + input: steerInputText, + ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), + }; + return manager.steerTurn(managerInput); + }, + catch: (cause) => toRequestError(input.threadId, "turn/steer", cause), + }); + }); + const readThread: CodexAdapterShape["readThread"] = (threadId) => Effect.tryPromise({ try: () => manager.readThread(threadId), @@ -1629,6 +1734,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }, startSession, sendTurn, + steerTurn, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index d70010d3f..6f82a8848 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1031,6 +1031,15 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }); }); + const steerTurn: CopilotAdapterShape["steerTurn"] = () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: "Turn steering is not supported by GitHub Copilot.", + }), + ); + const respondToRequest: CopilotAdapterShape["respondToRequest"] = ( threadId, requestId, @@ -1152,6 +1161,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }, startSession, sendTurn, + steerTurn, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/GeminiAdapter.ts b/apps/server/src/provider/Layers/GeminiAdapter.ts index 65d0fb805..e2e761840 100644 --- a/apps/server/src/provider/Layers/GeminiAdapter.ts +++ b/apps/server/src/provider/Layers/GeminiAdapter.ts @@ -365,6 +365,14 @@ const makeGeminiAdapter = Effect.gen(function* () { }); const interruptTurn: GeminiAdapterShape["interruptTurn"] = () => Effect.void; + const steerTurn: GeminiAdapterShape["steerTurn"] = () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "gemini", + method: "turn/steer", + detail: "Turn steering is not supported by Gemini.", + }), + ); const respondToRequest: GeminiAdapterShape["respondToRequest"] = ( _threadId, _requestId, @@ -424,6 +432,7 @@ const makeGeminiAdapter = Effect.gen(function* () { }, startSession, sendTurn, + steerTurn, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.ts b/apps/server/src/provider/Layers/OpenClawAdapter.ts index 4bda212e4..bd7fce7e2 100644 --- a/apps/server/src/provider/Layers/OpenClawAdapter.ts +++ b/apps/server/src/provider/Layers/OpenClawAdapter.ts @@ -1014,6 +1014,15 @@ function makeOpenClawAdapter(options?: OpenClawAdapterLiveOptions) { }); }); + const steerTurn: OpenClawAdapterShape["steerTurn"] = () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: "Turn steering is not supported by OpenClaw sessions.", + }), + ); + // ── Adapter interface: respondToRequest ────────────────────── const respondToRequest: OpenClawAdapterShape["respondToRequest"] = ( @@ -1145,6 +1154,7 @@ function makeOpenClawAdapter(options?: OpenClawAdapterLiveOptions) { capabilities: { sessionModelSwitch: "restart-session" }, startSession, sendTurn, + steerTurn, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 9d8939d13..fca241b78 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -18,6 +18,7 @@ const fakeCodexAdapter: CodexAdapterShape = { capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), + steerTurn: vi.fn(), interruptTurn: vi.fn(), respondToRequest: vi.fn(), respondToUserInput: vi.fn(), @@ -35,6 +36,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), + steerTurn: vi.fn(), interruptTurn: vi.fn(), respondToRequest: vi.fn(), respondToUserInput: vi.fn(), @@ -52,6 +54,7 @@ const fakeOpenClawAdapter: OpenClawAdapterShape = { capabilities: { sessionModelSwitch: "restart-session" }, startSession: vi.fn(), sendTurn: vi.fn(), + steerTurn: vi.fn(), interruptTurn: vi.fn(), respondToRequest: vi.fn(), respondToUserInput: vi.fn(), @@ -69,6 +72,7 @@ const fakeCopilotAdapter: CopilotAdapterShape = { capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), + steerTurn: vi.fn(), interruptTurn: vi.fn(), respondToRequest: vi.fn(), respondToUserInput: vi.fn(), diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cc4511cf7..90c51ddf1 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -7,6 +7,8 @@ * * @module ProviderHealthLive */ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; import { CopilotClient } from "@github/copilot-sdk"; import type { ServerProvider, @@ -18,6 +20,7 @@ import { Array, Data, Effect, FileSystem, Layer, Option, Result, Stream } from " import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { serverBuildInfo } from "../../buildInfo.ts"; +import { buildCodexInitializeParams } from "../../codexAppServerManager.ts"; import { OpenclawGatewayClient, OpenclawGatewayClientError } from "../../openclaw/GatewayClient.ts"; import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { @@ -274,6 +277,102 @@ const runCodexCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +async function probeCodexAppServerThreadStart(): Promise { + const child = spawn("codex", ["app-server"], { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + await new Promise((resolve, reject) => { + let settled = false; + let nextId = 1; + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + const stdout = createInterface({ input: child.stdout }); + const stderrLines: string[] = []; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out while probing codex app-server thread/start readiness.")); + }, DEFAULT_TIMEOUT_MS); + + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + stdout.close(); + if (!child.killed) { + child.kill("SIGKILL"); + } + }; + + child.stderr.on("data", (chunk) => { + stderrLines.push(chunk.toString("utf-8")); + }); + + child.on("error", (error) => { + cleanup(); + reject(error); + }); + + child.on("exit", (code) => { + if (settled) return; + cleanup(); + reject( + new Error( + `Codex app-server exited before thread/start completed (code ${code ?? "unknown"}). ${stderrLines.join("").trim()}`, + ), + ); + }); + + stdout.on("line", (line) => { + let parsed: { id?: number; result?: unknown; error?: { message?: string } }; + try { + parsed = JSON.parse(line); + } catch { + return; + } + if (typeof parsed.id !== "number") { + return; + } + const request = pending.get(parsed.id); + if (!request) { + return; + } + pending.delete(parsed.id); + if (parsed.error) { + request.reject(new Error(parsed.error.message ?? "JSON-RPC request failed.")); + return; + } + request.resolve(parsed.result); + }); + + const sendRequest = (method: string, params?: unknown) => + new Promise((requestResolve, requestReject) => { + const id = nextId++; + pending.set(id, { resolve: requestResolve, reject: requestReject }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })}\n`, + ); + }); + + void (async () => { + try { + await sendRequest("initialize", buildCodexInitializeParams()); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "initialized" })}\n`); + await sendRequest("thread/start", {}); + cleanup(); + resolve(); + } catch (error) { + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + } + })(); + }); +} + const runClaudeCommand = (args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -555,6 +654,27 @@ export const checkCodexProviderStatus: Effect.Effect< } const parsed = parseAuthStatusFromOutput(authProbe.success.value); + if (parsed.authStatus === "unauthenticated") { + const runtimeProbe = yield* Effect.tryPromise({ + try: () => probeCodexAppServerThreadStart(), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe(Effect.result); + + if (Result.isSuccess(runtimeProbe)) { + return createServerProviderStatus({ + provider: CODEX_PROVIDER, + enabled: true, + installed: true, + version: nonEmptyVersion(version.stdout, version.stderr), + status: "ready" as const, + auth: { status: "unknown" as const }, + checkedAt, + message: + "Codex app-server can start turns with the current configuration even though `codex login status` did not report an authenticated account.", + }); + } + } + return createServerProviderStatus({ provider: CODEX_PROVIDER, enabled: true, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 53f16a9c5..a1f0b91a9 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -24,6 +24,7 @@ import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { + ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderUnsupportedError, ProviderValidationError, @@ -109,6 +110,17 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { Effect.void, ); + const steerTurn = vi.fn( + (): Effect.Effect => + Effect.fail( + new ProviderAdapterRequestError({ + provider, + method: "turn/steer", + detail: "Turn steering is not supported in this test adapter.", + }), + ), + ); + const respondToRequest = vi.fn( ( _threadId: ThreadId, @@ -179,6 +191,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { }, startSession, sendTurn, + steerTurn, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index bbc11a745..cae5f7fe7 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -17,6 +17,7 @@ import { ProviderRespondToUserInputInput, ProviderSendTurnInput, ProviderSessionStartInput, + ProviderSteerTurnInput, ProviderStopSessionInput, type ProviderRuntimeEvent, type ProviderSession, @@ -364,6 +365,26 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => return turn; }); + const steerTurn: ProviderServiceShape["steerTurn"] = (rawInput) => + Effect.gen(function* () { + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderService.steerTurn", + schema: ProviderSteerTurnInput, + payload: rawInput, + }); + + const input = { + ...parsed, + attachments: parsed.attachments ?? [], + }; + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.steerTurn", + allowRecovery: true, + }); + yield* routed.adapter.steerTurn(input); + }); + const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => Effect.gen(function* () { const input = yield* decodeInputOrValidationError({ @@ -537,6 +558,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => return { startSession, sendTurn, + steerTurn, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index aa8b281e4..ce7e6c89e 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -16,6 +16,7 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderSteerTurnInput, ThreadId, ProviderTurnStartResult, TurnId, @@ -63,6 +64,11 @@ export interface ProviderAdapterShape { input: ProviderSendTurnInput, ) => Effect.Effect; + /** + * Inject user input into an active provider turn. + */ + readonly steerTurn: (input: ProviderSteerTurnInput) => Effect.Effect; + /** * Interrupt an active turn. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index b90ea2612..a15341215 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -20,6 +20,7 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderSteerTurnInput, ProviderStopSessionInput, ThreadId, ProviderTurnStartResult, @@ -49,6 +50,11 @@ export interface ProviderServiceShape { input: ProviderSendTurnInput, ) => Effect.Effect; + /** + * Inject user input into an active provider turn. + */ + readonly steerTurn: (input: ProviderSteerTurnInput) => Effect.Effect; + /** * Interrupt a running provider turn. */ diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index 35e0ea225..ccfe3b7ab 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -94,6 +94,7 @@ function makeProviderServiceLayer() { const providerService: ProviderServiceShape = { startSession: () => Effect.die("not used in this test"), sendTurn: () => Effect.die("not used in this test"), + steerTurn: () => Effect.die("not used in this test"), interruptTurn: () => Effect.void, respondToRequest: () => Effect.void, respondToUserInput: () => Effect.void, diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index 844f0bbd2..f77de334e 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -999,6 +999,10 @@ export function clearWorkspaceIndexCache(cwd: string): void { export async function listWorkspaceDirectory( input: ProjectListDirectoryInput, ): Promise { + if (input.shallow) { + return await listWorkspaceDirectoryShallow(input); + } + const index = await getWorkspaceIndex(input.cwd); const parentKey = input.directoryPath ?? ROOT_PARENT_KEY; const entries = index.entriesByParent.get(parentKey) ?? []; @@ -1025,6 +1029,77 @@ export async function listWorkspaceDirectory( }; } +async function listWorkspaceDirectoryShallow( + input: ProjectListDirectoryInput, +): Promise { + const relativeDirectoryPath = input.directoryPath?.trim() ?? ""; + const absoluteDirectoryPath = path.resolve(input.cwd, relativeDirectoryPath || "."); + const normalizedRelativePath = path.relative(input.cwd, absoluteDirectoryPath); + + if (normalizedRelativePath.startsWith("..") || path.isAbsolute(normalizedRelativePath)) { + throw new Error(`Directory path escapes workspace root: ${relativeDirectoryPath || "."}`); + } + + const directoryEntries = await fs.readdir(absoluteDirectoryPath, { withFileTypes: true }); + const entries: ProjectDirectoryEntry[] = []; + + for (const dirent of directoryEntries.toSorted((left, right) => + left.name.localeCompare(right.name), + )) { + if (!dirent.name || dirent.name === "." || dirent.name === "..") { + continue; + } + if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { + continue; + } + if (!dirent.isDirectory() && !dirent.isFile()) { + continue; + } + + const relativePath = toPosixPath( + relativeDirectoryPath ? path.join(relativeDirectoryPath, dirent.name) : dirent.name, + ); + if (isPathInIgnoredDirectory(relativePath)) { + continue; + } + + const entryAbsolutePath = path.join(absoluteDirectoryPath, dirent.name); + let hasChildren = false; + if (dirent.isDirectory()) { + try { + const childEntries = await fs.readdir(entryAbsolutePath, { withFileTypes: true }); + hasChildren = childEntries.some( + (child) => child.name && child.name !== "." && child.name !== "..", + ); + } catch { + hasChildren = false; + } + } + + const parentPath = parentPathOf(relativePath); + if (parentPath) { + entries.push({ + path: relativePath, + kind: dirent.isDirectory() ? "directory" : "file", + parentPath, + hasChildren, + }); + continue; + } + + entries.push({ + path: relativePath, + kind: dirent.isDirectory() ? "directory" : "file", + hasChildren, + }); + } + + return { + entries, + truncated: false, + }; +} + export async function searchWorkspaceEntries( input: ProjectSearchEntriesInput, ): Promise { diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index a82fc7bd3..ef1750cf7 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1525,6 +1525,7 @@ describe("WebSocket Server", () => { threadId, turnId: asTurnId("provider-turn-1"), }), + steerTurn: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 095318a98..761b6e905 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -549,13 +549,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } satisfies OrchestrationCommand; } - if (input.command.type !== "thread.turn.start") { + if (input.command.type !== "thread.turn.start" && input.command.type !== "thread.turn.steer") { return input.command as OrchestrationCommand; } - const turnStartCommand = input.command; + const turnCommand = input.command; const normalizedAttachments = yield* Effect.forEach( - turnStartCommand.message.attachments, + turnCommand.message.attachments, (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); @@ -598,7 +598,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } } - const attachmentId = createAttachmentId(turnStartCommand.threadId); + const attachmentId = createAttachmentId(turnCommand.threadId); if (!attachmentId) { return yield* new RouteRequestError({ message: "Failed to create a safe attachment id.", @@ -655,9 +655,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); return { - ...turnStartCommand, + ...turnCommand, message: { - ...turnStartCommand.message, + ...turnCommand.message, attachments: normalizedAttachments, }, } satisfies OrchestrationCommand; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b86f81c36..3c2b9a9c0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -541,6 +541,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; + const [steeredMessageIds, setSteeredMessageIds] = useState>({}); const [queuedMessages, setQueuedMessages] = useState([]); const queuedMessagesRef = useRef(queuedMessages); queuedMessagesRef.current = queuedMessages; @@ -981,6 +982,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { const isTransportReady = transportState === "open"; const isRemoteActionBlocked = !isTransportReady; const isWorking = isTurnActive || isSendBusy || isConnecting || isRevertingCheckpoint; + const canInterruptComposerWork = isTurnActive || isSendBusy || isConnecting; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -1243,16 +1245,34 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { return changed ? { ...message, attachments } : message; }); + const serverMessagesWithLocalMarkers = + Object.keys(steeredMessageIds).length === 0 + ? serverMessagesWithPreviewHandoff + : // Spread only applies to the few messages with a local steer marker. + // We keep copy-on-write semantics here to avoid mutating server state. + // oxlint-disable-next-line no-map-spread + serverMessagesWithPreviewHandoff.map((message) => { + if (message.role !== "user" || !steeredMessageIds[message.id]) { + return message; + } + return message.steered ? message : { ...message, steered: true }; + }); + if (optimisticUserMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return serverMessagesWithLocalMarkers; } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); + const serverIds = new Set(serverMessagesWithLocalMarkers.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return serverMessagesWithLocalMarkers; } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + return [...serverMessagesWithLocalMarkers, ...pendingMessages]; + }, [ + serverMessages, + attachmentPreviewHandoffByMessageId, + optimisticUserMessages, + steeredMessageIds, + ]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), @@ -3491,6 +3511,79 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { }, ); const queuedId = newMessageId(); + const canSteerActiveTurn = activeThread.session?.provider === "codex"; + + if (canSteerActiveTurn) { + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: queuedId, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: messageCreatedAt, + streaming: false, + steered: true, + }, + ]); + setSteeredMessageIds((existing) => ({ ...existing, [queuedId]: true })); + shouldAutoScrollRef.current = true; + forceStickToBottom(); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + void (async () => { + const steerAttachments = await Promise.all( + composerAttachmentsSnapshot.map(async (attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await readFileAsDataUrl(attachment.file), + })), + ); + await api.orchestration.dispatchCommand({ + type: "thread.turn.steer", + commandId: newCommandId(), + threadId: activeThread.id, + message: { + messageId: queuedId, + role: "user", + text: outgoingMessageText, + attachments: steerAttachments, + }, + ...(hiddenProviderInput ? { providerInput: hiddenProviderInput } : {}), + createdAt: messageCreatedAt, + }); + })().catch((err: unknown) => { + setOptimisticUserMessages((existing) => existing.filter((msg) => msg.id !== queuedId)); + setSteeredMessageIds((existing) => { + if (!existing[queuedId]) return existing; + const next = { ...existing }; + delete next[queuedId]; + return next; + }); + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to steer active turn.", + ); + }); + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "omitted", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + return; + } + setQueuedMessages((existing) => [ ...existing, { @@ -3854,12 +3947,18 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread || isRemoteActionBlocked) return; + const activeTurnId = activeThread.session?.activeTurnId ?? undefined; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", commandId: newCommandId(), threadId: activeThread.id, + ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}), createdAt: new Date().toISOString(), }); + if (activeTurnId === undefined) { + sendInFlightRef.current = false; + resetSendPhase(); + } }; const onClearQueue = useCallback(() => { @@ -5607,13 +5706,14 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { : "Next question"} - ) : isTurnActive ? ( + ) : canInterruptComposerWork ? (
- + + + ) : null}
) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( diff --git a/apps/web/src/components/CloneRepositoryDialog.tsx b/apps/web/src/components/CloneRepositoryDialog.tsx index 717d9bdea..7872c1ee6 100644 --- a/apps/web/src/components/CloneRepositoryDialog.tsx +++ b/apps/web/src/components/CloneRepositoryDialog.tsx @@ -1,10 +1,14 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FolderOpenIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isElectron } from "~/env"; import { gitCloneRepositoryMutationOptions } from "~/lib/gitReactQuery"; import { parseGitHubRepositoryUrl, type ParsedGitHubUrl } from "~/githubRepositoryUrl"; +import { deriveRemoteFolderBrowserRoot, isProbablyLocalWebSession } from "~/lib/remoteFolderPicker"; +import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { readNativeApi } from "~/nativeApi"; +import { RemoteFolderPickerDialog } from "./RemoteFolderPickerDialog"; import { Button } from "./ui/button"; import { Dialog, @@ -17,6 +21,7 @@ import { } from "./ui/dialog"; import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; +import { toastManager } from "./ui/toast"; interface CloneRepositoryDialogProps { open: boolean; @@ -35,9 +40,19 @@ export function CloneRepositoryDialog({ const [urlDirty, setUrlDirty] = useState(false); const [targetDir, setTargetDir] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); + const [remoteFolderPickerOpen, setRemoteFolderPickerOpen] = useState(false); const cloneMutation = useMutation(gitCloneRepositoryMutationOptions({ queryClient })); const { reset: resetCloneMutation } = cloneMutation; + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const shouldUseWebFolderBrowser = !isElectron && !isProbablyLocalWebSession(); + const canUseRemoteFolderBrowser = + shouldUseWebFolderBrowser && Boolean(serverConfigQuery.data?.cwd); + const remoteFolderBrowserRoot = useMemo( + () => + serverConfigQuery.data?.cwd ? deriveRemoteFolderBrowserRoot(serverConfigQuery.data.cwd) : "", + [serverConfigQuery.data?.cwd], + ); const parsed: ParsedGitHubUrl | null = useMemo(() => parseGitHubRepositoryUrl(url), [url]); @@ -62,6 +77,18 @@ export function CloneRepositoryDialog({ const pickTargetDir = useCallback(async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; + if (shouldUseWebFolderBrowser) { + if (!canUseRemoteFolderBrowser) { + toastManager.add({ + type: "error", + title: "Folder browser is still loading", + description: "Wait a moment for the server path to load, then try again.", + }); + return; + } + setRemoteFolderPickerOpen(true); + return; + } setIsPickingFolder(true); try { @@ -74,7 +101,7 @@ export function CloneRepositoryDialog({ } finally { setIsPickingFolder(false); } - }, [isPickingFolder]); + }, [canUseRemoteFolderBrowser, isPickingFolder, shouldUseWebFolderBrowser]); const handleClone = useCallback(async () => { if (!parsed) { @@ -193,7 +220,7 @@ export function CloneRepositoryDialog({ disabled={isPickingFolder || cloneMutation.isPending} > - Browse + {shouldUseWebFolderBrowser ? "Browse server folders" : "Browse"} {parsed && targetDir ? ( @@ -232,6 +259,15 @@ export function CloneRepositoryDialog({ + ); } diff --git a/apps/web/src/components/RemoteFolderPickerDialog.tsx b/apps/web/src/components/RemoteFolderPickerDialog.tsx new file mode 100644 index 000000000..15fd85164 --- /dev/null +++ b/apps/web/src/components/RemoteFolderPickerDialog.tsx @@ -0,0 +1,193 @@ +import { useQuery } from "@tanstack/react-query"; +import { ChevronRightIcon, FolderIcon, HouseIcon, MoveUpIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { projectListDirectoryQueryOptions } from "~/lib/projectReactQuery"; +import { joinRemoteFolderPath, relativeRemoteFolderPath } from "~/lib/remoteFolderPicker"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { ScrollArea } from "./ui/scroll-area"; +import { Spinner } from "./ui/spinner"; + +interface RemoteFolderPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rootPath: string; + initialPath?: string | undefined; + title: string; + description: string; + onPick: (path: string) => void; +} + +export function RemoteFolderPickerDialog({ + open, + onOpenChange, + rootPath, + initialPath, + title, + description, + onPick, +}: RemoteFolderPickerDialogProps) { + const [currentRelativePath, setCurrentRelativePath] = useState(""); + + useEffect(() => { + if (!open) { + return; + } + setCurrentRelativePath(relativeRemoteFolderPath(initialPath, rootPath)); + }, [initialPath, open, rootPath]); + + const currentAbsolutePath = useMemo( + () => joinRemoteFolderPath(rootPath, currentRelativePath), + [currentRelativePath, rootPath], + ); + + const directoryQuery = useQuery( + projectListDirectoryQueryOptions({ + cwd: rootPath || null, + ...(currentRelativePath ? { directoryPath: currentRelativePath } : {}), + shallow: true, + enabled: open && rootPath.trim().length > 0, + }), + ); + + const directoryEntries = useMemo( + () => directoryQuery.data?.entries.filter((entry) => entry.kind === "directory") ?? [], + [directoryQuery.data?.entries], + ); + const isLoadingDirectories = + directoryQuery.isPending || + (directoryQuery.isFetching && directoryEntries.length === 0 && !directoryQuery.error); + + const pathSegments = useMemo( + () => (currentRelativePath ? currentRelativePath.split("/").filter(Boolean) : []), + [currentRelativePath], + ); + + const navigateUp = () => { + if (!currentRelativePath) { + return; + } + const nextSegments = pathSegments.slice(0, -1); + setCurrentRelativePath(nextSegments.join("/")); + }; + + const chooseCurrentFolder = () => { + onPick(currentAbsolutePath); + onOpenChange(false); + }; + + return ( + + + + {title} + {description} + + +
+
+ + Current folder +
+
+ + {pathSegments.map((segment, index) => { + const nextPath = pathSegments.slice(0, index + 1).join("/"); + return ( +
+ + +
+ ); + })} +
+
+ +
+ + +
+ +
+ +
+ {isLoadingDirectories ? ( +
+ + Loading folders... +
+ ) : directoryQuery.error ? ( +
+ {directoryQuery.error instanceof Error + ? directoryQuery.error.message + : "Unable to browse folders."} +
+ ) : directoryEntries.length === 0 ? ( +
No subfolders here.
+ ) : ( + directoryEntries.map((entry) => ( + + )) + )} +
+
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 680fc2e4e..70542bc72 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -61,6 +61,7 @@ import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog"; import { EditableThreadTitle } from "~/components/EditableThreadTitle"; import { ProjectIconEditorDialog } from "~/components/ProjectIconEditorDialog"; import { ProjectIcon } from "~/components/ProjectIcon"; +import { RemoteFolderPickerDialog } from "~/components/RemoteFolderPickerDialog"; import { useClientMode } from "~/hooks/useClientMode"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates"; @@ -83,6 +84,10 @@ import { isElectron } from "../env"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { shortcutLabelForCommand } from "../keybindings"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { + deriveRemoteFolderBrowserRoot, + isProbablyLocalWebSession, +} from "../lib/remoteFolderPicker"; import { serverConfigQueryOptions, serverUpdateQueryOptions } from "../lib/serverReactQuery"; import { cn, isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -574,10 +579,8 @@ export default function Sidebar() { strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ - ...serverConfigQueryOptions(), - select: (config) => config.keybindings, - }); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates(); const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); @@ -588,6 +591,7 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false); + const [remoteFolderPickerOpen, setRemoteFolderPickerOpen] = useState(false); const [cloneDialogOpen, setCloneDialogOpen] = useState(false); const [projectIconDialogOpen, setProjectIconDialogOpen] = useState(false); const [projectIconDialogProjectId, setProjectIconDialogProjectId] = useState( @@ -615,6 +619,14 @@ export default function Sidebar() { const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const shouldUseWebFolderBrowser = !isElectron && !isProbablyLocalWebSession(); + const canUseRemoteFolderBrowser = + shouldUseWebFolderBrowser && Boolean(serverConfigQuery.data?.cwd); + const remoteFolderBrowserRoot = useMemo( + () => + serverConfigQuery.data?.cwd ? deriveRemoteFolderBrowserRoot(serverConfigQuery.data.cwd) : "", + [serverConfigQuery.data?.cwd], + ); const { editingThreadId, draftTitle: editingThreadTitle, @@ -887,6 +899,18 @@ export default function Sidebar() { const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; + if (shouldUseWebFolderBrowser) { + if (!canUseRemoteFolderBrowser) { + toastManager.add({ + type: "error", + title: "Folder browser is still loading", + description: "Wait a moment for the server path to load, then try again.", + }); + return; + } + setRemoteFolderPickerOpen(true); + return; + } setIsPickingFolder(true); let pickedPath: string | null = null; try { @@ -2164,7 +2188,12 @@ export default function Sidebar() { aria-label="Open workspace" className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" onClick={() => { - useRightPanelStore.getState().open("workspace"); + const panelState = useRightPanelStore.getState(); + if (panelState.isOpen && panelState.activeTab === "workspace") { + panelState.close(); + return; + } + panelState.open("workspace"); }} /> } @@ -2217,7 +2246,11 @@ export default function Sidebar() { disabled={isPickingFolder || isAddingProject} > - {isPickingFolder ? "Picking folder..." : "Browse for folder"} + {isPickingFolder + ? "Picking folder..." + : shouldUseWebFolderBrowser + ? "Browse server folders" + : "Browse on this Mac"} - ) : isTurnActive ? ( + ) : canInterruptComposerWork ? (
- + + + ) : null}
) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( diff --git a/apps/web/src/components/CloneRepositoryDialog.tsx b/apps/web/src/components/CloneRepositoryDialog.tsx index 717d9bdea..7872c1ee6 100644 --- a/apps/web/src/components/CloneRepositoryDialog.tsx +++ b/apps/web/src/components/CloneRepositoryDialog.tsx @@ -1,10 +1,14 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FolderOpenIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isElectron } from "~/env"; import { gitCloneRepositoryMutationOptions } from "~/lib/gitReactQuery"; import { parseGitHubRepositoryUrl, type ParsedGitHubUrl } from "~/githubRepositoryUrl"; +import { deriveRemoteFolderBrowserRoot, isProbablyLocalWebSession } from "~/lib/remoteFolderPicker"; +import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { readNativeApi } from "~/nativeApi"; +import { RemoteFolderPickerDialog } from "./RemoteFolderPickerDialog"; import { Button } from "./ui/button"; import { Dialog, @@ -17,6 +21,7 @@ import { } from "./ui/dialog"; import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; +import { toastManager } from "./ui/toast"; interface CloneRepositoryDialogProps { open: boolean; @@ -35,9 +40,19 @@ export function CloneRepositoryDialog({ const [urlDirty, setUrlDirty] = useState(false); const [targetDir, setTargetDir] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); + const [remoteFolderPickerOpen, setRemoteFolderPickerOpen] = useState(false); const cloneMutation = useMutation(gitCloneRepositoryMutationOptions({ queryClient })); const { reset: resetCloneMutation } = cloneMutation; + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const shouldUseWebFolderBrowser = !isElectron && !isProbablyLocalWebSession(); + const canUseRemoteFolderBrowser = + shouldUseWebFolderBrowser && Boolean(serverConfigQuery.data?.cwd); + const remoteFolderBrowserRoot = useMemo( + () => + serverConfigQuery.data?.cwd ? deriveRemoteFolderBrowserRoot(serverConfigQuery.data.cwd) : "", + [serverConfigQuery.data?.cwd], + ); const parsed: ParsedGitHubUrl | null = useMemo(() => parseGitHubRepositoryUrl(url), [url]); @@ -62,6 +77,18 @@ export function CloneRepositoryDialog({ const pickTargetDir = useCallback(async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; + if (shouldUseWebFolderBrowser) { + if (!canUseRemoteFolderBrowser) { + toastManager.add({ + type: "error", + title: "Folder browser is still loading", + description: "Wait a moment for the server path to load, then try again.", + }); + return; + } + setRemoteFolderPickerOpen(true); + return; + } setIsPickingFolder(true); try { @@ -74,7 +101,7 @@ export function CloneRepositoryDialog({ } finally { setIsPickingFolder(false); } - }, [isPickingFolder]); + }, [canUseRemoteFolderBrowser, isPickingFolder, shouldUseWebFolderBrowser]); const handleClone = useCallback(async () => { if (!parsed) { @@ -193,7 +220,7 @@ export function CloneRepositoryDialog({ disabled={isPickingFolder || cloneMutation.isPending} > - Browse + {shouldUseWebFolderBrowser ? "Browse server folders" : "Browse"} {parsed && targetDir ? ( @@ -232,6 +259,15 @@ export function CloneRepositoryDialog({ + ); } diff --git a/apps/web/src/components/RemoteFolderPickerDialog.tsx b/apps/web/src/components/RemoteFolderPickerDialog.tsx new file mode 100644 index 000000000..15fd85164 --- /dev/null +++ b/apps/web/src/components/RemoteFolderPickerDialog.tsx @@ -0,0 +1,193 @@ +import { useQuery } from "@tanstack/react-query"; +import { ChevronRightIcon, FolderIcon, HouseIcon, MoveUpIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { projectListDirectoryQueryOptions } from "~/lib/projectReactQuery"; +import { joinRemoteFolderPath, relativeRemoteFolderPath } from "~/lib/remoteFolderPicker"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { ScrollArea } from "./ui/scroll-area"; +import { Spinner } from "./ui/spinner"; + +interface RemoteFolderPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rootPath: string; + initialPath?: string | undefined; + title: string; + description: string; + onPick: (path: string) => void; +} + +export function RemoteFolderPickerDialog({ + open, + onOpenChange, + rootPath, + initialPath, + title, + description, + onPick, +}: RemoteFolderPickerDialogProps) { + const [currentRelativePath, setCurrentRelativePath] = useState(""); + + useEffect(() => { + if (!open) { + return; + } + setCurrentRelativePath(relativeRemoteFolderPath(initialPath, rootPath)); + }, [initialPath, open, rootPath]); + + const currentAbsolutePath = useMemo( + () => joinRemoteFolderPath(rootPath, currentRelativePath), + [currentRelativePath, rootPath], + ); + + const directoryQuery = useQuery( + projectListDirectoryQueryOptions({ + cwd: rootPath || null, + ...(currentRelativePath ? { directoryPath: currentRelativePath } : {}), + shallow: true, + enabled: open && rootPath.trim().length > 0, + }), + ); + + const directoryEntries = useMemo( + () => directoryQuery.data?.entries.filter((entry) => entry.kind === "directory") ?? [], + [directoryQuery.data?.entries], + ); + const isLoadingDirectories = + directoryQuery.isPending || + (directoryQuery.isFetching && directoryEntries.length === 0 && !directoryQuery.error); + + const pathSegments = useMemo( + () => (currentRelativePath ? currentRelativePath.split("/").filter(Boolean) : []), + [currentRelativePath], + ); + + const navigateUp = () => { + if (!currentRelativePath) { + return; + } + const nextSegments = pathSegments.slice(0, -1); + setCurrentRelativePath(nextSegments.join("/")); + }; + + const chooseCurrentFolder = () => { + onPick(currentAbsolutePath); + onOpenChange(false); + }; + + return ( + + + + {title} + {description} + + +
+
+ + Current folder +
+
+ + {pathSegments.map((segment, index) => { + const nextPath = pathSegments.slice(0, index + 1).join("/"); + return ( +
+ + +
+ ); + })} +
+
+ +
+ + +
+ +
+ +
+ {isLoadingDirectories ? ( +
+ + Loading folders... +
+ ) : directoryQuery.error ? ( +
+ {directoryQuery.error instanceof Error + ? directoryQuery.error.message + : "Unable to browse folders."} +
+ ) : directoryEntries.length === 0 ? ( +
No subfolders here.
+ ) : ( + directoryEntries.map((entry) => ( + + )) + )} +
+
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 680fc2e4e..70542bc72 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -61,6 +61,7 @@ import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog"; import { EditableThreadTitle } from "~/components/EditableThreadTitle"; import { ProjectIconEditorDialog } from "~/components/ProjectIconEditorDialog"; import { ProjectIcon } from "~/components/ProjectIcon"; +import { RemoteFolderPickerDialog } from "~/components/RemoteFolderPickerDialog"; import { useClientMode } from "~/hooks/useClientMode"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates"; @@ -83,6 +84,10 @@ import { isElectron } from "../env"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { shortcutLabelForCommand } from "../keybindings"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { + deriveRemoteFolderBrowserRoot, + isProbablyLocalWebSession, +} from "../lib/remoteFolderPicker"; import { serverConfigQueryOptions, serverUpdateQueryOptions } from "../lib/serverReactQuery"; import { cn, isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -574,10 +579,8 @@ export default function Sidebar() { strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ - ...serverConfigQueryOptions(), - select: (config) => config.keybindings, - }); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates(); const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); @@ -588,6 +591,7 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false); + const [remoteFolderPickerOpen, setRemoteFolderPickerOpen] = useState(false); const [cloneDialogOpen, setCloneDialogOpen] = useState(false); const [projectIconDialogOpen, setProjectIconDialogOpen] = useState(false); const [projectIconDialogProjectId, setProjectIconDialogProjectId] = useState( @@ -615,6 +619,14 @@ export default function Sidebar() { const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const shouldUseWebFolderBrowser = !isElectron && !isProbablyLocalWebSession(); + const canUseRemoteFolderBrowser = + shouldUseWebFolderBrowser && Boolean(serverConfigQuery.data?.cwd); + const remoteFolderBrowserRoot = useMemo( + () => + serverConfigQuery.data?.cwd ? deriveRemoteFolderBrowserRoot(serverConfigQuery.data.cwd) : "", + [serverConfigQuery.data?.cwd], + ); const { editingThreadId, draftTitle: editingThreadTitle, @@ -887,6 +899,18 @@ export default function Sidebar() { const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; + if (shouldUseWebFolderBrowser) { + if (!canUseRemoteFolderBrowser) { + toastManager.add({ + type: "error", + title: "Folder browser is still loading", + description: "Wait a moment for the server path to load, then try again.", + }); + return; + } + setRemoteFolderPickerOpen(true); + return; + } setIsPickingFolder(true); let pickedPath: string | null = null; try { @@ -2164,7 +2188,12 @@ export default function Sidebar() { aria-label="Open workspace" className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" onClick={() => { - useRightPanelStore.getState().open("workspace"); + const panelState = useRightPanelStore.getState(); + if (panelState.isOpen && panelState.activeTab === "workspace") { + panelState.close(); + return; + } + panelState.open("workspace"); }} /> } @@ -2217,7 +2246,11 @@ export default function Sidebar() { disabled={isPickingFolder || isAddingProject} > - {isPickingFolder ? "Picking folder..." : "Browse for folder"} + {isPickingFolder + ? "Picking folder..." + : shouldUseWebFolderBrowser + ? "Browse server folders" + : "Browse on this Mac"}