From 563bbfec7ca98b2a653f84a9b6ecb72a9ee87f71 Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:25:30 +0200 Subject: [PATCH 1/9] feat: resume Claude Code sessions and enable Remote Control Finish the in-progress ServerListClaudeResumableSessions/resumeExternalSessionId scaffolding: a ClaudeSessionHistory service reads on-disk Claude transcripts to list resumable sessions for a workspace, the server binds a picked session to a new thread before its first turn dispatches, and a new "Resume a Claude session" picker in the new-thread screen drives it end to end. Also adds a per-instance Remote Control toggle for Claude provider settings, launching sessions with --remote-control so they can be attached to from claude.ai or the Claude mobile app. --- .../src/provider/Drivers/ClaudeSkills.ts | 2 +- .../src/provider/Layers/ClaudeAdapter.ts | 5 +- .../provider/Layers/ClaudeSessionHistory.ts | 189 ++++++++++++++++++ apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 39 ++++ apps/web/src/components/ChatView.tsx | 8 + .../src/components/chat/DraftHeroHeadline.tsx | 31 ++- .../components/chat/ResumeSessionDialog.tsx | 163 +++++++++++++++ apps/web/src/localApi.ts | 1 + apps/web/src/resumeSessionIntentStore.ts | 33 +++ packages/client-runtime/src/state/server.ts | 4 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/orchestration.ts | 8 + packages/contracts/src/rpc.ts | 13 ++ packages/contracts/src/server.ts | 27 +++ packages/contracts/src/settings.ts | 11 +- 16 files changed, 527 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeSessionHistory.ts create mode 100644 apps/web/src/components/chat/ResumeSessionDialog.tsx create mode 100644 apps/web/src/resumeSessionIntentStore.ts diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 335c3d4681dfe..d4e2e5e55d30e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -61,7 +61,7 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` * already present in the process environment, then `~/.claude`. */ -const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( +export const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( config: Pick, environment: NodeJS.ProcessEnv, cwd?: string, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 285d9dac60887..20cf85e310f7f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3484,7 +3484,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; - const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; + const extraArgs = { + ...parseCliArgs(claudeSettings.launchArgs).flags, + ...(claudeSettings.remoteControl ? { "remote-control": null } : {}), + }; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); diff --git a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts new file mode 100644 index 0000000000000..be63b1a0f5372 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts @@ -0,0 +1,189 @@ +/** + * ClaudeSessionHistory — reads Claude Code's on-disk session transcripts to + * list resumable sessions for a given workspace root, powering the + * `server.listClaudeResumableSessions` RPC (the "resume a previous Claude + * Code session" picker). Claude-only: no other provider's on-disk transcript + * format is understood here. + * + * Claude Code stores one directory per working directory under + * `/projects/`, named by replacing every non-alphanumeric + * character in the absolute cwd with `-` (so `/Users/m/Documents/t3` becomes + * `-Users-m-Documents-t3`). Each session is a `.jsonl` file inside + * that directory — a stream of JSON records (`user`/`assistant` messages plus + * assorted metadata lines) that Claude Code itself replays for `--resume`. + * + * @module provider/Layers/ClaudeSessionHistory + */ +import type { + ServerClaudeResumableSession, + ServerListClaudeResumableSessionsInput, + ServerListClaudeResumableSessionsResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { resolveClaudeConfigDirPath } from "../Drivers/ClaudeSkills.ts"; + +const JSONL_EXTENSION = ".jsonl"; +const MAX_LABEL_LENGTH = 80; + +function encodeCwdForClaudeProjectDir(cwd: string): string { + return cwd.replace(/[^a-zA-Z0-9]/g, "-"); +} + +function truncateLabel(text: string): string { + const trimmed = text.trim(); + return trimmed.length > MAX_LABEL_LENGTH + ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` + : trimmed; +} + +function extractTextFromUserContent(content: unknown): string | undefined { + if (typeof content === "string") { + return content.trim().length > 0 ? content : undefined; + } + if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + const text = (block as { text: string }).text; + if (text.trim().length > 0) return text; + } + } + } + return undefined; +} + +interface TranscriptSummary { + readonly latestTimestampMs: number | undefined; + readonly label: string | undefined; + readonly messageCount: number; +} + +function summarizeTranscript(raw: string): TranscriptSummary { + let latestTimestampMs: number | undefined; + let label: string | undefined; + let messageCount = 0; + + for (const line of raw.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) continue; + + let entry: unknown; + try { + entry = JSON.parse(trimmedLine); + } catch { + continue; + } + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const type = typeof record.type === "string" ? record.type : undefined; + + if (typeof record.timestamp === "string") { + const ms = Date.parse(record.timestamp); + if (Number.isFinite(ms) && (latestTimestampMs === undefined || ms > latestTimestampMs)) { + latestTimestampMs = ms; + } + } + + if (type === "user" || type === "assistant") { + messageCount += 1; + } + + if (label === undefined && type === "user") { + const message = record.message; + const content = + message && typeof message === "object" + ? (message as Record).content + : undefined; + const extracted = extractTextFromUserContent(content); + if (extracted !== undefined) { + label = truncateLabel(extracted); + } + } + } + + return { latestTimestampMs, label, messageCount }; +} + +export class ClaudeSessionHistory extends Context.Service< + ClaudeSessionHistory, + { + readonly list: ( + input: ServerListClaudeResumableSessionsInput, + ) => Effect.Effect; + } +>()("t3/provider/Layers/ClaudeSessionHistory") {} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Resolved once per server process: `CLAUDE_CONFIG_DIR` isn't expected to + // change over the server's lifetime, and resolving it here (rather than + // per-call) keeps `list`'s effect free of a `Path.Path` requirement. + const configDirPath = yield* resolveClaudeConfigDirPath({ homePath: "" }, process.env); + + const list: ClaudeSessionHistory["Service"]["list"] = Effect.fn( + "ClaudeSessionHistory.list", + )(function* (input) { + const projectDirPath = path.join( + configDirPath, + "projects", + encodeCwdForClaudeProjectDir(input.workspaceRoot), + ); + + const entries = yield* fileSystem + .readDirectory(projectDirPath) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const sessions = yield* Effect.forEach( + entries.filter((entry) => entry.endsWith(JSONL_EXTENSION)), + (entry) => + Effect.gen(function* () { + const sessionId = entry.slice(0, -JSONL_EXTENSION.length); + const filePath = path.join(projectDirPath, entry); + const raw = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (raw === undefined) return undefined; + + const summary = summarizeTranscript(raw); + const lastActiveAtMs = + summary.latestTimestampMs ?? + (yield* fileSystem.stat(filePath).pipe( + Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime()), + Effect.orElseSucceed(() => undefined), + )); + if (lastActiveAtMs === undefined || summary.messageCount === 0) return undefined; + + return { + sessionId, + cwd: input.workspaceRoot, + label: summary.label ?? null, + lastActiveAt: new Date(lastActiveAtMs).toISOString(), + messageCount: summary.messageCount, + } satisfies ServerClaudeResumableSession; + }), + { concurrency: 8 }, + ); + + const resolvedSessions = sessions.filter( + (session): session is ServerClaudeResumableSession => session !== undefined, + ); + resolvedSessions.sort((left, right) => right.lastActiveAt.localeCompare(left.lastActiveAt)); + + return { sessions: resolvedSessions }; + }); + + return ClaudeSessionHistory.of({ list }); +}); + +export const layer = Layer.effect(ClaudeSessionHistory, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3e0..902fb1dfa2cdd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -83,6 +83,7 @@ import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; +import * as ClaudeSessionHistory from "./provider/Layers/ClaudeSessionHistory.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { @@ -338,6 +339,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(ProcessDiagnostics.layer), Layer.provideMerge(ProcessResourceMonitor.layer), + Layer.provideMerge(ClaudeSessionHistory.layer), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76ed2..d5e7205f22db6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -44,6 +44,7 @@ import { ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, + ProviderDriverKind, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, @@ -101,6 +102,10 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as ClaudeSessionHistory from "./provider/Layers/ClaudeSessionHistory.ts"; +import { ProviderValidationError } from "./provider/Errors.ts"; +import * as ProviderAdapterRegistry from "./provider/Services/ProviderAdapterRegistry.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -285,6 +290,10 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). const SHELL_RESUME_MAX_GAP = 1_000; +// The only provider whose on-disk transcript format `resumeExternalSessionId` +// is derived from and verified against. See `ClaudeSessionHistory.ts`. +const CLAUDE_RESUME_PROVIDER = ProviderDriverKind.make("claudeAgent"); + const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], @@ -306,6 +315,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], + [WS_METHODS.serverListClaudeResumableSessions, AuthOrchestrationReadScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], @@ -447,6 +457,9 @@ const makeWsRpcLayer = ( const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; + const claudeSessionHistory = yield* ClaudeSessionHistory.ClaudeSessionHistory; + const providerAdapterRegistry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -993,6 +1006,24 @@ const makeWsRpcLayer = ( createdAt: bootstrap.createThread.createdAt, }); createdThread = true; + + const resumeExternalSessionId = bootstrap.createThread.resumeExternalSessionId; + if (resumeExternalSessionId) { + const instanceId = bootstrap.createThread.modelSelection.instanceId; + const instanceInfo = yield* providerAdapterRegistry.getInstanceInfo(instanceId); + if (instanceInfo.driverKind !== CLAUDE_RESUME_PROVIDER) { + return yield* new ProviderValidationError({ + operation: "ws.dispatchBootstrapTurnStart", + issue: `resumeExternalSessionId is only supported for Claude Code provider instances; '${instanceId}' is a '${instanceInfo.driverKind}' instance.`, + }); + } + yield* providerSessionDirectory.upsert({ + threadId: command.threadId, + provider: instanceInfo.driverKind, + providerInstanceId: instanceId, + resumeCursor: { resume: resumeExternalSessionId }, + }); + } } if (bootstrap?.prepareWorktree) { @@ -1552,6 +1583,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverListClaudeResumableSessions]: (input) => + observeRpcEffect( + WS_METHODS.serverListClaudeResumableSessions, + claudeSessionHistory.list(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverSignalProcess]: (input) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb34f..b122f8b4563d9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -177,6 +177,7 @@ import { useComposerDraftStore, type DraftId, } from "../composerDraftStore"; +import { useResumeSessionIntentStore } from "../resumeSessionIntentStore"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, @@ -4718,6 +4719,9 @@ function ChatViewContent(props: ChatViewProps) { let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { + const resumeExternalSessionId = isLocalDraftThread + ? (useResumeSessionIntentStore.getState().intentByThreadId[activeThread.id] ?? undefined) + : undefined; const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { @@ -4732,6 +4736,7 @@ function ChatViewContent(props: ChatViewProps) { branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, + ...(resumeExternalSessionId ? { resumeExternalSessionId } : {}), }, } : {}), @@ -4771,6 +4776,9 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + if (resumeExternalSessionId) { + useResumeSessionIntentStore.getState().clearResumeSessionIntent(activeThread.id); + } } } diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab694..9a0ec9bca64c7 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,7 +1,7 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { FolderPlusIcon } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { FolderPlusIcon, HistoryIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; @@ -23,6 +23,7 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; +import { ResumeSessionDialog } from "./ResumeSessionDialog"; interface DraftHeroHeadlineProps { readonly activeProjectRef: ScopedProjectRef | null; @@ -41,6 +42,7 @@ export function DraftHeroHeadline({ const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); const environmentLabelById = useMemo( () => @@ -132,6 +134,10 @@ export function DraftHeroHeadline({ New project + setIsResumeDialogOpen(true)}> + + Resume a Claude session + ) : ( @@ -145,14 +151,17 @@ export function DraftHeroHeadline({ ); return ( -

- {hasResolvedProject ? ( - <>What should we build in {projectSelector}? - ) : canChooseProject ? ( - <>{projectSelector} to start - ) : ( - <>Add a project to start - )} -

+ <> +

+ {hasResolvedProject ? ( + <>What should we build in {projectSelector}? + ) : canChooseProject ? ( + <>{projectSelector} to start + ) : ( + <>Add a project to start + )} +

+ + ); } diff --git a/apps/web/src/components/chat/ResumeSessionDialog.tsx b/apps/web/src/components/chat/ResumeSessionDialog.tsx new file mode 100644 index 0000000000000..500ea12cba07d --- /dev/null +++ b/apps/web/src/components/chat/ResumeSessionDialog.tsx @@ -0,0 +1,163 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { ChevronLeftIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useResumeSessionIntentStore } from "~/resumeSessionIntentStore"; +import { useProjects } from "~/state/entities"; +import { useEnvironmentQuery } from "~/state/query"; +import { serverEnvironment } from "~/state/server"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { Dialog, DialogDescription, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog"; + +interface ResumeSessionDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogProps) { + const projects = useProjects(); + const handleNewThread = useNewThreadHandler(); + const setResumeSessionIntent = useResumeSessionIntentStore( + (store) => store.setResumeSessionIntent, + ); + const [selectedProject, setSelectedProject] = useState(null); + const [isResuming, setIsResuming] = useState(false); + + const sortedProjects = useMemo( + () => [...projects].sort((left, right) => left.title.localeCompare(right.title)), + [projects], + ); + + const { + data: sessionsResult, + error: sessionsError, + isPending: sessionsPending, + } = useEnvironmentQuery( + selectedProject === null + ? null + : serverEnvironment.listClaudeResumableSessions({ + environmentId: selectedProject.environmentId, + input: { workspaceRoot: selectedProject.workspaceRoot }, + }), + ); + const sessions = useMemo( + () => + [...(sessionsResult?.sessions ?? [])].sort((left, right) => + right.lastActiveAt.localeCompare(left.lastActiveAt), + ), + [sessionsResult], + ); + + const handleClose = useCallback( + (next: boolean) => { + onOpenChange(next); + if (!next) setSelectedProject(null); + }, + [onOpenChange], + ); + + const handleResume = useCallback( + async (sessionId: string) => { + if (!selectedProject || isResuming) return; + setIsResuming(true); + try { + const projectRef = scopeProjectRef(selectedProject.environmentId, selectedProject.id); + await handleNewThread(projectRef); + const draftThread = useComposerDraftStore + .getState() + .getDraftThreadByProjectRef(projectRef); + if (draftThread) { + setResumeSessionIntent(draftThread.threadId, sessionId); + } + handleClose(false); + } finally { + setIsResuming(false); + } + }, + [selectedProject, isResuming, handleNewThread, setResumeSessionIntent, handleClose], + ); + + return ( + + + + Resume a Claude Code session + + {selectedProject + ? `Pick a previous session from ${selectedProject.title} to continue.` + : "Pick a project, then pick which on-disk session to resume."} + + + + {selectedProject === null ? ( + sortedProjects.length === 0 ? ( +

+ Add a project first to see resumable sessions. +

+ ) : ( + sortedProjects.map((project) => ( + + )) + ) + ) : ( + <> + + {sessionsPending ? ( +

+ Looking for sessions… +

+ ) : sessionsError ? ( +

{sessionsError}

+ ) : sessions.length === 0 ? ( +

+ No resumable Claude Code sessions found for this project. +

+ ) : ( + sessions.map((session) => ( + + )) + )} + + )} +
+
+
+ ); +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2fbf183f91b0b..a2f39f99bef44 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -75,6 +75,7 @@ function createBrowserLocalApi(): LocalApi { getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()), getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), + listClaudeResumableSessions: () => Promise.reject(unavailableLocalBackendError()), signalProcess: () => Promise.reject(unavailableLocalBackendError()), }, }; diff --git a/apps/web/src/resumeSessionIntentStore.ts b/apps/web/src/resumeSessionIntentStore.ts new file mode 100644 index 0000000000000..557ddfb2a9ee8 --- /dev/null +++ b/apps/web/src/resumeSessionIntentStore.ts @@ -0,0 +1,33 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; + +/** + * Ephemeral (non-persisted) link between a not-yet-created draft thread and + * an on-disk Claude Code session the user picked to resume. Deliberately + * kept outside `composerDraftStore` (which is persisted and drives a lot of + * draft-lifecycle logic) — this only needs to survive from "user picked a + * session in the resume dialog" to "user sends the first message of that + * draft", at which point `ChatView` consumes and clears it. + */ +interface ResumeSessionIntentStoreState { + readonly intentByThreadId: Partial>; + readonly setResumeSessionIntent: (threadId: ThreadId, resumeExternalSessionId: string) => void; + readonly clearResumeSessionIntent: (threadId: ThreadId) => void; +} + +export const useResumeSessionIntentStore = create((set) => ({ + intentByThreadId: {}, + setResumeSessionIntent: (threadId, resumeExternalSessionId) => { + set((state) => ({ + intentByThreadId: { ...state.intentByThreadId, [threadId]: resumeExternalSessionId }, + })); + }, + clearResumeSessionIntent: (threadId) => { + set((state) => { + if (!(threadId in state.intentByThreadId)) return state; + const next = { ...state.intentByThreadId }; + delete next[threadId]; + return { intentByThreadId: next }; + }); + }, +})); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ea7f5fb6d7557..108c9e357ef3e 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -298,6 +298,10 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + listClaudeResumableSessions: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:list-claude-resumable-sessions", + tag: WS_METHODS.serverListClaudeResumableSessions, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d792627..fadcf807a6621 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -34,6 +34,8 @@ import type { import type { ProviderInstanceId } from "./providerInstance.ts"; import type { ServerConfig, + ServerListClaudeResumableSessionsInput, + ServerListClaudeResumableSessionsResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, @@ -1154,6 +1156,9 @@ export interface LocalApi { getProcessResourceHistory: ( input: ServerProcessResourceHistoryInput, ) => Promise; + listClaudeResumableSessions: ( + input: ServerListClaudeResumableSessionsInput, + ) => Promise; signalProcess: (input: ServerSignalProcessInput) => Promise; }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f9f..1af08d76e8a5a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -648,6 +648,14 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, + // Claude-only "resume a previous session" support: when set, the server + // binds this brand-new thread's provider session directory entry to the + // named on-disk Claude Code session (`--resume `) immediately after + // `thread.create` succeeds, before the first turn is dispatched. See + // `apps/server/src/ws.ts`'s `dispatchBootstrapTurnStart`. Scoped to Claude + // because only Claude's on-disk transcript format is verified; other + // providers' resume cursors are not derived from this field. + resumeExternalSessionId: Schema.optional(TrimmedNonEmptyString), }); const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef296..f85586a0dde99 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -129,6 +129,8 @@ import { ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, + ServerListClaudeResumableSessionsInput, + ServerListClaudeResumableSessionsResult, ServerSignalProcessInput, ServerSignalProcessResult, ServerUpsertKeybindingInput, @@ -217,6 +219,7 @@ export const WS_METHODS = { serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetProcessResourceHistory: "server.getProcessResourceHistory", + serverListClaudeResumableSessions: "server.listClaudeResumableSessions", serverSignalProcess: "server.signalProcess", // Cloud environment methods @@ -328,6 +331,15 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( }, ); +export const WsServerListClaudeResumableSessionsRpc = Rpc.make( + WS_METHODS.serverListClaudeResumableSessions, + { + payload: ServerListClaudeResumableSessionsInput, + success: ServerListClaudeResumableSessionsResult, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -712,6 +724,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, + WsServerListClaudeResumableSessionsRpc, WsServerSignalProcessRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a8394c..8accf03595cac 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -393,6 +393,33 @@ export const ServerProcessResourceHistoryResult = Schema.Struct({ }); export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHistoryResult.Type; +/** + * One resumable Claude Code session discovered on disk for a given + * workspace root — read from `/projects//*.jsonl` + * transcripts. Claude-only: other providers' on-disk session formats are + * unverified. See `apps/server/src/provider/Layers/ClaudeSessionHistory.ts`. + */ +export const ServerClaudeResumableSession = Schema.Struct({ + sessionId: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyString, + label: Schema.NullOr(TrimmedNonEmptyString), + lastActiveAt: IsoDateTime, + messageCount: NonNegativeInt, +}); +export type ServerClaudeResumableSession = typeof ServerClaudeResumableSession.Type; + +export const ServerListClaudeResumableSessionsInput = Schema.Struct({ + workspaceRoot: TrimmedNonEmptyString, +}); +export type ServerListClaudeResumableSessionsInput = + typeof ServerListClaudeResumableSessionsInput.Type; + +export const ServerListClaudeResumableSessionsResult = Schema.Struct({ + sessions: Schema.Array(ServerClaudeResumableSession), +}); +export type ServerListClaudeResumableSessionsResult = + typeof ServerListClaudeResumableSessionsResult.Type; + export const ServerSignalProcessInput = Schema.Struct({ pid: PositiveInt, signal: ServerProcessSignal, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67ab..4794c8d3015e8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -273,9 +273,17 @@ export const ClaudeSettings = makeProviderSettingsSchema( }, }), ), + remoteControl: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ + title: "Remote Control", + description: + "Start sessions with Claude Code's Remote Control enabled (--remote-control), so you can attach from claude.ai or the Claude mobile app.", + }), + ), }, { - order: ["binaryPath", "homePath", "launchArgs"], + order: ["binaryPath", "homePath", "launchArgs", "remoteControl"], }, ); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -513,6 +521,7 @@ const ClaudeSettingsPatch = Schema.Struct({ homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), launchArgs: Schema.optionalKey(TrimmedString), + remoteControl: Schema.optionalKey(Schema.Boolean), }); const CursorSettingsPatch = Schema.Struct({ From 45010ed41aca1c2f20213403a6df01d712b5086d Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:18:46 +0200 Subject: [PATCH 2/9] fix: wire ClaudeSessionHistory's provider dependencies correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the resume-session bind logic (instance lookup + ProviderSessionDirectory upsert) into ClaudeSessionHistory itself instead of ws.ts, using the publicly exposed ProviderInstanceRegistry rather than the private ProviderAdapterRegistry facade. Explicitly provide ClaudeSessionHistory with RuntimeCoreDependenciesLive in server.ts, since Layer.provideMerge only threads the *base* layer's remaining requirements forward, not the newly added layer's own — ws.ts's direct yield* on these services was leaking an unmet requirement all the way up through bin.ts's CLI composition and every hand-built test layer stack. Also fixes the test fixtures/mocks this exposed: missing `remoteControl` on ClaudeSettings test literals, and a pipe() argument-count overflow in server.test.ts's mock chain once the new ClaudeSessionHistory mock pushed it past the 20-argument overload limit. --- .../provider/Layers/ClaudeSessionHistory.ts | 183 ++++++++++++------ .../ProviderInstanceRegistryLive.test.ts | 1 + apps/server/src/server.test.ts | 35 ++-- apps/server/src/server.ts | 8 +- apps/server/src/serverSettings.test.ts | 2 + apps/server/src/ws.ts | 25 +-- 6 files changed, 156 insertions(+), 98 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts index be63b1a0f5372..3f9819867b5f2 100644 --- a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts +++ b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts @@ -1,9 +1,11 @@ /** * ClaudeSessionHistory — reads Claude Code's on-disk session transcripts to - * list resumable sessions for a given workspace root, powering the - * `server.listClaudeResumableSessions` RPC (the "resume a previous Claude - * Code session" picker). Claude-only: no other provider's on-disk transcript - * format is understood here. + * list resumable sessions for a given workspace root, and binds a picked + * session to a thread's provider session directory entry. Powers the + * `server.listClaudeResumableSessions` RPC and the bootstrap-time resume + * binding in `ws.ts`'s `dispatchBootstrapTurnStart` (the "resume a previous + * Claude Code session" picker end to end). Claude-only: no other provider's + * on-disk transcript format is understood here. * * Claude Code stores one directory per working directory under * `/projects/`, named by replacing every non-alphanumeric @@ -12,14 +14,29 @@ * that directory — a stream of JSON records (`user`/`assistant` messages plus * assorted metadata lines) that Claude Code itself replays for `--resume`. * + * `getInstance`/`upsert` (via `ProviderInstanceRegistry` / + * `ProviderSessionDirectory`) are required here, inside this service, rather + * than being `yield*`'d directly in `ws.ts` — `ws.ts`'s own top-level R is + * threaded through several hand-composed layer stacks (server tests, `bin.ts`) + * that don't otherwise know about provider internals, so keeping this + * dependency contained to a single already-wired service (this one) avoids + * leaking it into all of those call sites. `ProviderInstanceRegistry` (not + * `ProviderAdapterRegistry`, which `server.ts` keeps private to + * `ProviderServiceLive`'s own construction) is the instance→driver-kind + * lookup that's actually exposed by the composed runtime. + * * @module provider/Layers/ClaudeSessionHistory */ -import type { - ServerClaudeResumableSession, - ServerListClaudeResumableSessionsInput, - ServerListClaudeResumableSessionsResult, +import { + ProviderDriverKind, + type ProviderInstanceId, + type ServerClaudeResumableSession, + type ServerListClaudeResumableSessionsInput, + type ServerListClaudeResumableSessionsResult, + type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -27,6 +44,12 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { resolveClaudeConfigDirPath } from "../Drivers/ClaudeSkills.ts"; +import { ProviderValidationError } from "../Errors.ts"; +import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; +import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; +import type { ProviderSessionDirectoryWriteError } from "../Services/ProviderSessionDirectory.ts"; + +const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); const JSONL_EXTENSION = ".jsonl"; const MAX_LABEL_LENGTH = 80; @@ -37,9 +60,7 @@ function encodeCwdForClaudeProjectDir(cwd: string): string { function truncateLabel(text: string): string { const trimmed = text.trim(); - return trimmed.length > MAX_LABEL_LENGTH - ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` - : trimmed; + return trimmed.length > MAX_LABEL_LENGTH ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` : trimmed; } function extractTextFromUserContent(content: unknown): string | undefined { @@ -114,76 +135,116 @@ function summarizeTranscript(raw: string): TranscriptSummary { return { latestTimestampMs, label, messageCount }; } +export interface ClaudeSessionHistoryBindResumeSessionInput { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + readonly resumeExternalSessionId: string; +} + export class ClaudeSessionHistory extends Context.Service< ClaudeSessionHistory, { readonly list: ( input: ServerListClaudeResumableSessionsInput, ) => Effect.Effect; + /** + * Binds a picked on-disk Claude session to a thread's provider session + * directory entry, so the next turn resumes it via `--resume `. + * Fails if the target provider instance isn't a Claude instance. + */ + readonly bindResumeSession: ( + input: ClaudeSessionHistoryBindResumeSessionInput, + ) => Effect.Effect; } >()("t3/provider/Layers/ClaudeSessionHistory") {} export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const providerInstanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; // Resolved once per server process: `CLAUDE_CONFIG_DIR` isn't expected to // change over the server's lifetime, and resolving it here (rather than // per-call) keeps `list`'s effect free of a `Path.Path` requirement. const configDirPath = yield* resolveClaudeConfigDirPath({ homePath: "" }, process.env); - const list: ClaudeSessionHistory["Service"]["list"] = Effect.fn( - "ClaudeSessionHistory.list", + const list: ClaudeSessionHistory["Service"]["list"] = Effect.fn("ClaudeSessionHistory.list")( + function* (input) { + const projectDirPath = path.join( + configDirPath, + "projects", + encodeCwdForClaudeProjectDir(input.workspaceRoot), + ); + + const entries = yield* fileSystem + .readDirectory(projectDirPath) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const sessions = yield* Effect.forEach( + entries.filter((entry) => entry.endsWith(JSONL_EXTENSION)), + (entry) => + Effect.gen(function* () { + const sessionId = entry.slice(0, -JSONL_EXTENSION.length); + const filePath = path.join(projectDirPath, entry); + const raw = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (raw === undefined) return undefined; + + const summary = summarizeTranscript(raw); + const lastActiveAtMs = + summary.latestTimestampMs ?? + (yield* fileSystem.stat(filePath).pipe( + Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime()), + Effect.orElseSucceed(() => undefined), + )); + if (lastActiveAtMs === undefined || summary.messageCount === 0) return undefined; + + return { + sessionId, + cwd: input.workspaceRoot, + label: summary.label ?? null, + lastActiveAt: DateTime.formatIso(DateTime.makeUnsafe(lastActiveAtMs)), + messageCount: summary.messageCount, + } satisfies ServerClaudeResumableSession; + }), + { concurrency: 8 }, + ); + + const resolvedSessions = sessions.filter( + (session): session is ServerClaudeResumableSession => session !== undefined, + ); + resolvedSessions.sort((left, right) => right.lastActiveAt.localeCompare(left.lastActiveAt)); + + return { sessions: resolvedSessions }; + }, + ); + + const bindResumeSession: ClaudeSessionHistory["Service"]["bindResumeSession"] = Effect.fn( + "ClaudeSessionHistory.bindResumeSession", )(function* (input) { - const projectDirPath = path.join( - configDirPath, - "projects", - encodeCwdForClaudeProjectDir(input.workspaceRoot), - ); - - const entries = yield* fileSystem - .readDirectory(projectDirPath) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - - const sessions = yield* Effect.forEach( - entries.filter((entry) => entry.endsWith(JSONL_EXTENSION)), - (entry) => - Effect.gen(function* () { - const sessionId = entry.slice(0, -JSONL_EXTENSION.length); - const filePath = path.join(projectDirPath, entry); - const raw = yield* fileSystem - .readFileString(filePath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (raw === undefined) return undefined; - - const summary = summarizeTranscript(raw); - const lastActiveAtMs = - summary.latestTimestampMs ?? - (yield* fileSystem.stat(filePath).pipe( - Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime()), - Effect.orElseSucceed(() => undefined), - )); - if (lastActiveAtMs === undefined || summary.messageCount === 0) return undefined; - - return { - sessionId, - cwd: input.workspaceRoot, - label: summary.label ?? null, - lastActiveAt: new Date(lastActiveAtMs).toISOString(), - messageCount: summary.messageCount, - } satisfies ServerClaudeResumableSession; - }), - { concurrency: 8 }, - ); - - const resolvedSessions = sessions.filter( - (session): session is ServerClaudeResumableSession => session !== undefined, - ); - resolvedSessions.sort((left, right) => right.lastActiveAt.localeCompare(left.lastActiveAt)); - - return { sessions: resolvedSessions }; + const instance = yield* providerInstanceRegistry.getInstance(input.providerInstanceId); + if (!instance) { + return yield* new ProviderValidationError({ + operation: "ClaudeSessionHistory.bindResumeSession", + issue: `Provider instance '${input.providerInstanceId}' is not configured.`, + }); + } + if (instance.driverKind !== CLAUDE_PROVIDER) { + return yield* new ProviderValidationError({ + operation: "ClaudeSessionHistory.bindResumeSession", + issue: `resumeExternalSessionId is only supported for Claude Code provider instances; '${input.providerInstanceId}' is a '${instance.driverKind}' instance.`, + }); + } + yield* providerSessionDirectory.upsert({ + threadId: input.threadId, + provider: instance.driverKind, + providerInstanceId: input.providerInstanceId, + resumeCursor: { resume: input.resumeExternalSessionId }, + }); }); - return ClaudeSessionHistory.of({ list }); + return ClaudeSessionHistory.of({ list, bindResumeSession }); }); export const layer = Layer.effect(ClaudeSessionHistory, make); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 384de852f9b53..f99b4e635b332 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -72,6 +72,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => homePath: "", customModels: [], launchArgs: "", + remoteControl: false, ...overrides, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90ea..e91124b254368 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -114,6 +114,7 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as ClaudeSessionHistory from "./provider/Layers/ClaudeSessionHistory.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -594,20 +595,26 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ - readHistory: (input) => - Effect.succeed({ - readAt: TEST_EPOCH, - windowMs: input.windowMs, - bucketMs: input.bucketMs, - sampleIntervalMs: 5_000, - retainedSampleCount: 0, - totalCpuSecondsApprox: 0, - buckets: [], - topProcesses: [], - error: Option.none(), - }), - }), + Layer.mergeAll( + Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ + readHistory: (input) => + Effect.succeed({ + readAt: TEST_EPOCH, + windowMs: input.windowMs, + bucketMs: input.bucketMs, + sampleIntervalMs: 5_000, + retainedSampleCount: 0, + totalCpuSecondsApprox: 0, + buckets: [], + topProcesses: [], + error: Option.none(), + }), + }), + Layer.mock(ClaudeSessionHistory.ClaudeSessionHistory)({ + list: () => Effect.succeed({ sessions: [] }), + bindResumeSession: () => Effect.void, + }), + ), ), Layer.provide( Layer.mock(TraceDiagnostics.TraceDiagnostics)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 902fb1dfa2cdd..3f2b73f4d53f2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -339,7 +339,13 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(ProcessDiagnostics.layer), Layer.provideMerge(ProcessResourceMonitor.layer), - Layer.provideMerge(ClaudeSessionHistory.layer), + // `Layer.provideMerge(dependency)` only feeds `dependency`'s output into + // *this* layer's requirements — it does not resolve `dependency`'s own + // requirements from `RuntimeCoreDependenciesLive`. `ClaudeSessionHistory` + // needs `ProviderInstanceRegistry`/`ProviderSessionDirectory` (only + // exposed via `RuntimeCoreDependenciesLive`'s own composition), so it's + // explicitly provided that here rather than relying on ambient accumulation. + Layer.provideMerge(ClaudeSessionHistory.layer.pipe(Layer.provide(RuntimeCoreDependenciesLive))), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 487ae9b45b8a4..797f999f10b7b 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -188,6 +188,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: ["claude-custom"], launchArgs: "", + remoteControl: false, }); assert.deepEqual( next.textGenerationModelSelection, @@ -430,6 +431,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: [], launchArgs: "", + remoteControl: false, }); assert.deepEqual(next.providers.opencode, { enabled: true, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d5e7205f22db6..5eb7b2b1122e0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -44,7 +44,6 @@ import { ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, - ProviderDriverKind, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, @@ -103,9 +102,6 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as ClaudeSessionHistory from "./provider/Layers/ClaudeSessionHistory.ts"; -import { ProviderValidationError } from "./provider/Errors.ts"; -import * as ProviderAdapterRegistry from "./provider/Services/ProviderAdapterRegistry.ts"; -import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -290,10 +286,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). const SHELL_RESUME_MAX_GAP = 1_000; -// The only provider whose on-disk transcript format `resumeExternalSessionId` -// is derived from and verified against. See `ClaudeSessionHistory.ts`. -const CLAUDE_RESUME_PROVIDER = ProviderDriverKind.make("claudeAgent"); - const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], @@ -458,8 +450,6 @@ const makeWsRpcLayer = ( const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const claudeSessionHistory = yield* ClaudeSessionHistory.ClaudeSessionHistory; - const providerAdapterRegistry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; - const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1009,19 +999,10 @@ const makeWsRpcLayer = ( const resumeExternalSessionId = bootstrap.createThread.resumeExternalSessionId; if (resumeExternalSessionId) { - const instanceId = bootstrap.createThread.modelSelection.instanceId; - const instanceInfo = yield* providerAdapterRegistry.getInstanceInfo(instanceId); - if (instanceInfo.driverKind !== CLAUDE_RESUME_PROVIDER) { - return yield* new ProviderValidationError({ - operation: "ws.dispatchBootstrapTurnStart", - issue: `resumeExternalSessionId is only supported for Claude Code provider instances; '${instanceId}' is a '${instanceInfo.driverKind}' instance.`, - }); - } - yield* providerSessionDirectory.upsert({ + yield* claudeSessionHistory.bindResumeSession({ threadId: command.threadId, - provider: instanceInfo.driverKind, - providerInstanceId: instanceId, - resumeCursor: { resume: resumeExternalSessionId }, + providerInstanceId: bootstrap.createThread.modelSelection.instanceId, + resumeExternalSessionId, }); } } From 690110ed0d2d9b16b0aa6f5d4070bab404c9c503 Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:14:24 +0200 Subject: [PATCH 3/9] fix: resolve reviewer-flagged resume-session bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Force the resumed draft onto the same Claude provider instance the session list came from, instead of inheriting whatever provider was carried/sticky from the previous thread — a non-Claude selection made the resume attempt get rejected once the first message sent. - Resolve the on-disk session store per Claude *instance* (respecting a configured custom homePath) instead of always scanning the default ~/.claude location, by threading providerInstanceId through the list RPC and decoding that instance's actual ClaudeSettings. - Force "local" env mode (no worktree) for resume-initiated drafts — --resume is scoped to the exact cwd the session was created under, and a worktree would run Claude from a different directory. - Stop relying on useNewThreadHandler's reuse-existing-draft heuristics for this flow (they could no-op when the picked project was already the active draft, silently dropping the resume intent). Create the draft directly and set the resume intent against a known threadId, with a toast on failure instead of silently closing the dialog. --- .../provider/Layers/ClaudeSessionHistory.ts | 37 +++++- .../components/chat/ResumeSessionDialog.tsx | 118 +++++++++++++++--- packages/contracts/src/server.ts | 5 + 3 files changed, 142 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts index 3f9819867b5f2..70a62c015c1b2 100644 --- a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts +++ b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts @@ -28,6 +28,7 @@ * @module provider/Layers/ClaudeSessionHistory */ import { + ClaudeSettings, ProviderDriverKind, type ProviderInstanceId, type ServerClaudeResumableSession, @@ -42,14 +43,18 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { resolveClaudeConfigDirPath } from "../Drivers/ClaudeSkills.ts"; import { ProviderValidationError } from "../Errors.ts"; +import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; import type { ProviderSessionDirectoryWriteError } from "../Services/ProviderSessionDirectory.ts"; const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); +const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); const JSONL_EXTENSION = ".jsonl"; const MAX_LABEL_LENGTH = 80; @@ -163,13 +168,37 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const providerInstanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; - // Resolved once per server process: `CLAUDE_CONFIG_DIR` isn't expected to - // change over the server's lifetime, and resolving it here (rather than - // per-call) keeps `list`'s effect free of a `Path.Path` requirement. - const configDirPath = yield* resolveClaudeConfigDirPath({ homePath: "" }, process.env); + const serverSettings = yield* ServerSettingsService; + + // Claude instances can be configured with a custom `homePath` (keeping + // config/session storage separate per instance), so the config dir must be + // resolved per instance, not once for the whole process. Falls back to the + // default `~/.claude` location when the instance is unknown/misconfigured + // rather than failing the whole listing — best-effort, matching `list`'s + // other file-not-found handling. + const resolveConfigDirPathForInstance = Effect.fn("ClaudeSessionHistory.resolveConfigDirPath")( + function* (providerInstanceId: ProviderInstanceId | undefined) { + const homePath = yield* Effect.gen(function* () { + if (providerInstanceId === undefined) return ""; + const settings = yield* serverSettings.getSettings; + const configMap = deriveProviderInstanceConfigMap(settings); + const entry = configMap[providerInstanceId]; + const decoded = yield* decodeClaudeSettings(entry?.config ?? {}); + return decoded.homePath; + }).pipe(Effect.orElseSucceed(() => "")); + // `resolveClaudeConfigDirPath` yields its own `Path.Path` requirement; + // satisfy it from the already-resolved `path` in scope so this stays + // free of a `Path.Path` requirement of its own (see the `list`/`make` + // split above — required for `Layer.provideMerge` to fully resolve it). + return yield* resolveClaudeConfigDirPath({ homePath }, process.env).pipe( + Effect.provideService(Path.Path, path), + ); + }, + ); const list: ClaudeSessionHistory["Service"]["list"] = Effect.fn("ClaudeSessionHistory.list")( function* (input) { + const configDirPath = yield* resolveConfigDirPathForInstance(input.providerInstanceId); const projectDirPath = path.join( configDirPath, "projects", diff --git a/apps/web/src/components/chat/ResumeSessionDialog.tsx b/apps/web/src/components/chat/ResumeSessionDialog.tsx index 500ea12cba07d..fd4fc2eb7e17b 100644 --- a/apps/web/src/components/chat/ResumeSessionDialog.tsx +++ b/apps/web/src/components/chat/ResumeSessionDialog.tsx @@ -1,16 +1,38 @@ +import { useAtomValue } from "@effect/atom-react"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { useRouter } from "@tanstack/react-router"; import { ChevronLeftIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; -import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useClientSettings } from "~/hooks/useSettings"; +import { newDraftId, newThreadId } from "~/lib/utils"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "~/logicalProject"; +import { + getDefaultProviderInstanceModel, + resolveSelectableProviderInstance, +} from "~/providerInstances"; import { useResumeSessionIntentStore } from "~/resumeSessionIntentStore"; import { useProjects } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { formatRelativeTimeLabel } from "~/timestampFormat"; -import { Dialog, DialogDescription, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; + +const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); interface ResumeSessionDialogProps { readonly open: boolean; @@ -19,7 +41,8 @@ interface ResumeSessionDialogProps { export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogProps) { const projects = useProjects(); - const handleNewThread = useNewThreadHandler(); + const router = useRouter(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const setResumeSessionIntent = useResumeSessionIntentStore( (store) => store.setResumeSessionIntent, ); @@ -31,6 +54,25 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP [projects], ); + const serverConfig = useAtomValue( + serverEnvironment.configValueAtom(selectedProject?.environmentId ?? null), + ); + const claudeProviders = useMemo( + () => (serverConfig?.providers ?? []).filter((provider) => provider.driver === CLAUDE_DRIVER), + [serverConfig], + ); + const claudeInstanceId = useMemo( + () => resolveSelectableProviderInstance(claudeProviders, undefined), + [claudeProviders], + ); + const claudeModel = useMemo( + () => + claudeInstanceId + ? getDefaultProviderInstanceModel(claudeProviders, claudeInstanceId) + : undefined, + [claudeProviders, claudeInstanceId], + ); + const { data: sessionsResult, error: sessionsError, @@ -40,7 +82,10 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP ? null : serverEnvironment.listClaudeResumableSessions({ environmentId: selectedProject.environmentId, - input: { workspaceRoot: selectedProject.workspaceRoot }, + input: { + workspaceRoot: selectedProject.workspaceRoot, + ...(claudeInstanceId ? { providerInstanceId: claudeInstanceId } : {}), + }, }), ); const sessions = useMemo( @@ -61,23 +106,64 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP const handleResume = useCallback( async (sessionId: string) => { - if (!selectedProject || isResuming) return; + if (!selectedProject || !claudeInstanceId || !claudeModel || isResuming) return; setIsResuming(true); try { const projectRef = scopeProjectRef(selectedProject.environmentId, selectedProject.id); - await handleNewThread(projectRef); - const draftThread = useComposerDraftStore - .getState() - .getDraftThreadByProjectRef(projectRef); - if (draftThread) { - setResumeSessionIntent(draftThread.threadId, sessionId); - } + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + selectedProject, + projectGroupingSettings, + ); + const draftId = newDraftId(); + const threadId = newThreadId(); + const { setLogicalProjectDraftThreadId, setModelSelection } = + useComposerDraftStore.getState(); + // Force "local" (no worktree): the on-disk session was recorded under + // the project's own workspaceRoot, and Claude's `--resume` is scoped + // to the exact cwd it was created in — a worktree would run Claude + // from a different directory and the resume would silently miss. + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { + threadId, + createdAt: new Date().toISOString(), + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }); + // Force the draft onto the same Claude instance the session list came + // from — otherwise the draft could inherit a carried-over/sticky + // selection for a different provider, and the resume would be + // rejected once the first message is sent. + setModelSelection( + draftId, + { instanceId: claudeInstanceId, model: claudeModel }, + { replaceOptions: true }, + ); + setResumeSessionIntent(threadId, sessionId); + await router.navigate({ to: "/draft/$draftId", params: { draftId } }); handleClose(false); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to resume session", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } finally { setIsResuming(false); } }, - [selectedProject, isResuming, handleNewThread, setResumeSessionIntent, handleClose], + [ + selectedProject, + claudeInstanceId, + claudeModel, + isResuming, + projectGroupingSettings, + router, + setResumeSessionIntent, + handleClose, + ], ); return ( @@ -124,7 +210,11 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP Back to projects - {sessionsPending ? ( + {!claudeInstanceId || !claudeModel ? ( +

+ No Claude Code provider is configured for this environment. +

+ ) : sessionsPending ? (

Looking for sessions…

diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8accf03595cac..0c217a69881ee 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -410,6 +410,11 @@ export type ServerClaudeResumableSession = typeof ServerClaudeResumableSession.T export const ServerListClaudeResumableSessionsInput = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, + // Which configured Claude instance's on-disk session store to scan. + // Instances can be configured with a custom `homePath`, so this must match + // the instance the caller intends to resume under — the default instance + // location is used only as a fallback when omitted. + providerInstanceId: Schema.optional(ProviderInstanceId), }); export type ServerListClaudeResumableSessionsInput = typeof ServerListClaudeResumableSessionsInput.Type; From 1914b61cea38fdeee56ffbc62a7f7f20230fcd26 Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:25:58 +0200 Subject: [PATCH 4/9] fix: show a visible banner when a Claude session resume is pending Picking a session in the resume dialog was working correctly under the hood (new draft created, resume intent set) but landed on a completely blank "new thread" screen with zero indication anything had happened, which read as "nothing happens" when clicking a session. Add a dismissible composer banner ("Resuming a previous Claude session" + the session's label) for the lifetime of the pending intent, using the existing ComposerBannerStack mechanism. Carry the session's label through resumeSessionIntentStore (previously just the raw session id) so the banner has something to display. --- apps/web/src/components/ChatView.tsx | 27 +++++++++++++++++-- .../components/chat/ResumeSessionDialog.tsx | 7 ++++- apps/web/src/resumeSessionIntentStore.ts | 17 ++++++++---- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b122f8b4563d9..1a257ee623986 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -144,6 +144,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + HistoryIcon, TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; @@ -4095,13 +4096,33 @@ function ChatViewContent(props: ChatViewProps) { } void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); + const pendingResumeSessionIntent = useResumeSessionIntentStore((store) => + isLocalDraftThread && activeThread ? (store.intentByThreadId[activeThread.id] ?? null) : null, + ); + const resumeSessionBannerItem = useMemo(() => { + if (!pendingResumeSessionIntent || !isLocalDraftThread || !activeThread) return null; + const draftThreadId = activeThread.id; + return { + id: `resume-session:${pendingResumeSessionIntent.resumeExternalSessionId}`, + variant: "info", + icon: , + title: "Resuming a previous Claude session", + description: pendingResumeSessionIntent.label ?? undefined, + dismissLabel: "Cancel resume", + onDismiss: () => { + useResumeSessionIntentStore.getState().clearResumeSessionIntent(draftThreadId); + }, + }; + }, [pendingResumeSessionIntent, isLocalDraftThread, activeThread]); const composerBannerItems = useMemo(() => { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + const resumeSessionItems = resumeSessionBannerItem === null ? [] : [resumeSessionBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [...systemComposerBannerItems, ...parkedThreadItems]; + return [...systemComposerBannerItems, ...resumeSessionItems, ...parkedThreadItems]; } return [ ...systemComposerBannerItems, + ...resumeSessionItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", @@ -4145,6 +4166,7 @@ function ChatViewContent(props: ChatViewProps) { ]; }, [ activeBranchMismatchKey, + resumeSessionBannerItem, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, @@ -4720,7 +4742,8 @@ function ChatViewContent(props: ChatViewProps) { let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const resumeExternalSessionId = isLocalDraftThread - ? (useResumeSessionIntentStore.getState().intentByThreadId[activeThread.id] ?? undefined) + ? useResumeSessionIntentStore.getState().intentByThreadId[activeThread.id] + ?.resumeExternalSessionId : undefined; const bootstrap = isLocalDraftThread || baseBranchForWorktree diff --git a/apps/web/src/components/chat/ResumeSessionDialog.tsx b/apps/web/src/components/chat/ResumeSessionDialog.tsx index fd4fc2eb7e17b..e991affd3a381 100644 --- a/apps/web/src/components/chat/ResumeSessionDialog.tsx +++ b/apps/web/src/components/chat/ResumeSessionDialog.tsx @@ -139,7 +139,11 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP { instanceId: claudeInstanceId, model: claudeModel }, { replaceOptions: true }, ); - setResumeSessionIntent(threadId, sessionId); + const session = sessions.find((candidate) => candidate.sessionId === sessionId); + setResumeSessionIntent(threadId, { + resumeExternalSessionId: sessionId, + label: session?.label ?? null, + }); await router.navigate({ to: "/draft/$draftId", params: { draftId } }); handleClose(false); } catch (error) { @@ -161,6 +165,7 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP isResuming, projectGroupingSettings, router, + sessions, setResumeSessionIntent, handleClose, ], diff --git a/apps/web/src/resumeSessionIntentStore.ts b/apps/web/src/resumeSessionIntentStore.ts index 557ddfb2a9ee8..8ca716d8a7206 100644 --- a/apps/web/src/resumeSessionIntentStore.ts +++ b/apps/web/src/resumeSessionIntentStore.ts @@ -7,19 +7,26 @@ import { create } from "zustand"; * kept outside `composerDraftStore` (which is persisted and drives a lot of * draft-lifecycle logic) — this only needs to survive from "user picked a * session in the resume dialog" to "user sends the first message of that - * draft", at which point `ChatView` consumes and clears it. + * draft", at which point `ChatView` consumes and clears it. The `label` is + * carried alongside purely for display (the "Resuming session" composer + * banner) — it plays no role in the actual resume mechanism. */ +export interface ResumeSessionIntent { + readonly resumeExternalSessionId: string; + readonly label: string | null; +} + interface ResumeSessionIntentStoreState { - readonly intentByThreadId: Partial>; - readonly setResumeSessionIntent: (threadId: ThreadId, resumeExternalSessionId: string) => void; + readonly intentByThreadId: Partial>; + readonly setResumeSessionIntent: (threadId: ThreadId, intent: ResumeSessionIntent) => void; readonly clearResumeSessionIntent: (threadId: ThreadId) => void; } export const useResumeSessionIntentStore = create((set) => ({ intentByThreadId: {}, - setResumeSessionIntent: (threadId, resumeExternalSessionId) => { + setResumeSessionIntent: (threadId, intent) => { set((state) => ({ - intentByThreadId: { ...state.intentByThreadId, [threadId]: resumeExternalSessionId }, + intentByThreadId: { ...state.intentByThreadId, [threadId]: intent }, })); }, clearResumeSessionIntent: (threadId) => { From 75336b379b61a44fdbbae632654a30aad6ab21b4 Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:49:01 +0200 Subject: [PATCH 5/9] feat: show imported history from the resumed Claude session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming a session correctly continued the model's context server-side, but T3's own thread view starts empty for the brand-new thread (it's a separate event-sourced history from Claude's on-disk transcript), so none of the prior conversation was visible — just the new message and a context-aware reply with no visible "why." Add a new server.getClaudeResumableSessionTranscript RPC that reads a picked session's full on-disk transcript back as flat, text-only messages (ClaudeSessionHistory.getTranscript). The resume dialog fetches it when a session is picked and stores it in a new, deliberately separate resumeSessionHistoryStore (unlike the pending-resume intent, this should keep rendering for the life of the thread, not just until the first message sends). ChatView merges it into the timeline ahead of the thread's own messages as plain, unfolded (turnId: null) rows. Deliberately client-side/display-only: these never become real orchestration events, avoiding the event-sourced store's invariants (the only command that can add a user-role message also triggers a live provider turn) — see the architecture note in resumeSessionHistoryStore.ts. --- .../provider/Layers/ClaudeSessionHistory.ts | 121 +++++++++++++++++- apps/server/src/server.test.ts | 1 + apps/server/src/ws.ts | 9 ++ apps/web/src/components/ChatView.tsx | 37 +++++- .../components/chat/ResumeSessionDialog.tsx | 25 ++++ apps/web/src/localApi.ts | 1 + apps/web/src/resumeSessionHistoryStore.ts | 39 ++++++ packages/client-runtime/src/state/server.ts | 4 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/rpc.ts | 13 ++ packages/contracts/src/server.ts | 30 +++++ 11 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/resumeSessionHistoryStore.ts diff --git a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts index 70a62c015c1b2..422d9bb139c71 100644 --- a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts +++ b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts @@ -32,6 +32,9 @@ import { ProviderDriverKind, type ProviderInstanceId, type ServerClaudeResumableSession, + type ServerClaudeResumableSessionMessage, + type ServerGetClaudeResumableSessionTranscriptInput, + type ServerGetClaudeResumableSessionTranscriptResult, type ServerListClaudeResumableSessionsInput, type ServerListClaudeResumableSessionsResult, type ThreadId, @@ -58,6 +61,7 @@ const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); const JSONL_EXTENSION = ".jsonl"; const MAX_LABEL_LENGTH = 80; +const MAX_TRANSCRIPT_MESSAGES = 200; function encodeCwdForClaudeProjectDir(cwd: string): string { return cwd.replace(/[^a-zA-Z0-9]/g, "-"); @@ -88,6 +92,34 @@ function extractTextFromUserContent(content: unknown): string | undefined { return undefined; } +/** + * Concatenates every `type: "text"` block in a message's `content` (string + * or content-block array), skipping `tool_use`/`tool_result`/`thinking`/ + * image blocks. Used for full transcript import, where an assistant turn + * may interleave several text blocks around tool calls — unlike + * `extractTextFromUserContent` (label extraction only, first block wins), + * this collects all of them so imported history reads coherently. + */ +function extractAllTextBlocks(content: unknown): string | undefined { + if (typeof content === "string") { + return content.trim().length > 0 ? content : undefined; + } + if (!Array.isArray(content)) return undefined; + const parts: string[] = []; + for (const block of content) { + if ( + block && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + const text = (block as { text: string }).text; + if (text.trim().length > 0) parts.push(text); + } + } + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + interface TranscriptSummary { readonly latestTimestampMs: number | undefined; readonly label: string | undefined; @@ -140,6 +172,54 @@ function summarizeTranscript(raw: string): TranscriptSummary { return { latestTimestampMs, label, messageCount }; } +/** + * Parses a full transcript into flat, chronological, text-only messages for + * display-only import (see `getTranscript`). Plain (non-Effect) function so + * its `JSON.parse` calls stay outside any Effect generator. + */ +function parseTranscriptMessages( + raw: string, + sessionId: string, +): ServerClaudeResumableSessionMessage[] { + const messages: ServerClaudeResumableSessionMessage[] = []; + + for (const line of raw.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) continue; + + let entry: unknown; + try { + entry = JSON.parse(trimmedLine); + } catch { + continue; + } + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const type = record.type; + if (type !== "user" && type !== "assistant") continue; + const timestamp = record.timestamp; + if (typeof timestamp !== "string") continue; + + const message = record.message; + const content = + message && typeof message === "object" + ? (message as Record).content + : undefined; + const text = extractAllTextBlocks(content); + if (text === undefined) continue; + + const id = + typeof record.uuid === "string" && record.uuid.length > 0 + ? record.uuid + : `${sessionId}-${messages.length}`; + + messages.push({ id, role: type, text, createdAt: timestamp }); + } + + messages.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + return messages; +} + export interface ClaudeSessionHistoryBindResumeSessionInput { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; @@ -152,6 +232,15 @@ export class ClaudeSessionHistory extends Context.Service< readonly list: ( input: ServerListClaudeResumableSessionsInput, ) => Effect.Effect; + /** + * Reads a specific resumable session's on-disk transcript back as a flat, + * text-only message list, for display-only import into T3's own thread + * view (see `ResumeSessionDialog.tsx`). These never become real + * orchestration events/messages. + */ + readonly getTranscript: ( + input: ServerGetClaudeResumableSessionTranscriptInput, + ) => Effect.Effect; /** * Binds a picked on-disk Claude session to a thread's provider session * directory entry, so the next turn resumes it via `--resume `. @@ -196,14 +285,23 @@ export const make = Effect.gen(function* () { }, ); - const list: ClaudeSessionHistory["Service"]["list"] = Effect.fn("ClaudeSessionHistory.list")( - function* (input) { + const resolveProjectDirPath = Effect.fn("ClaudeSessionHistory.resolveProjectDirPath")( + function* (input: { + readonly workspaceRoot: string; + readonly providerInstanceId?: ProviderInstanceId | undefined; + }) { const configDirPath = yield* resolveConfigDirPathForInstance(input.providerInstanceId); - const projectDirPath = path.join( + return path.join( configDirPath, "projects", encodeCwdForClaudeProjectDir(input.workspaceRoot), ); + }, + ); + + const list: ClaudeSessionHistory["Service"]["list"] = Effect.fn("ClaudeSessionHistory.list")( + function* (input) { + const projectDirPath = yield* resolveProjectDirPath(input); const entries = yield* fileSystem .readDirectory(projectDirPath) @@ -249,6 +347,21 @@ export const make = Effect.gen(function* () { }, ); + const getTranscript: ClaudeSessionHistory["Service"]["getTranscript"] = Effect.fn( + "ClaudeSessionHistory.getTranscript", + )(function* (input) { + const projectDirPath = yield* resolveProjectDirPath(input); + const filePath = path.join(projectDirPath, `${input.sessionId}${JSONL_EXTENSION}`); + const raw = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + + const messages = parseTranscriptMessages(raw, input.sessionId); + const trimmedMessages = + messages.length > MAX_TRANSCRIPT_MESSAGES + ? messages.slice(messages.length - MAX_TRANSCRIPT_MESSAGES) + : messages; + return { messages: trimmedMessages }; + }); + const bindResumeSession: ClaudeSessionHistory["Service"]["bindResumeSession"] = Effect.fn( "ClaudeSessionHistory.bindResumeSession", )(function* (input) { @@ -273,7 +386,7 @@ export const make = Effect.gen(function* () { }); }); - return ClaudeSessionHistory.of({ list, bindResumeSession }); + return ClaudeSessionHistory.of({ list, getTranscript, bindResumeSession }); }); export const layer = Layer.effect(ClaudeSessionHistory, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e91124b254368..9a2a0ef65b368 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -612,6 +612,7 @@ const buildAppUnderTest = (options?: { }), Layer.mock(ClaudeSessionHistory.ClaudeSessionHistory)({ list: () => Effect.succeed({ sessions: [] }), + getTranscript: () => Effect.succeed({ messages: [] }), bindResumeSession: () => Effect.void, }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5eb7b2b1122e0..8f389eea69240 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -308,6 +308,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverListClaudeResumableSessions, AuthOrchestrationReadScope], + [WS_METHODS.serverGetClaudeResumableSessionTranscript, AuthOrchestrationReadScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], @@ -1572,6 +1573,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetClaudeResumableSessionTranscript]: (input) => + observeRpcEffect( + WS_METHODS.serverGetClaudeResumableSessionTranscript, + claudeSessionHistory.getTranscript(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverSignalProcess]: (input) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1a257ee623986..adb0aeaae6d2c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,7 +3,7 @@ import { DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, - type MessageId, + MessageId, type ModelSelection, type ProjectScript, type ProjectId, @@ -178,6 +178,10 @@ import { useComposerDraftStore, type DraftId, } from "../composerDraftStore"; +import { + type ResumeSessionHistoryMessage, + useResumeSessionHistoryStore, +} from "../resumeSessionHistoryStore"; import { useResumeSessionIntentStore } from "../resumeSessionIntentStore"; import { appendTerminalContextsToPrompt, @@ -452,6 +456,7 @@ function formatOutgoingPrompt(params: { } const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +const EMPTY_RESUME_HISTORY: ReadonlyArray = []; type ChatViewProps = | { @@ -2259,10 +2264,36 @@ function ChatViewContent(props: ChatViewProps) { } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); + const importedResumeHistoryMessages = useResumeSessionHistoryStore((store) => + activeThread + ? (store.historyByThreadId[activeThread.id] ?? EMPTY_RESUME_HISTORY) + : EMPTY_RESUME_HISTORY, + ); + const timelineMessagesWithImportedHistory = useMemo(() => { + if (importedResumeHistoryMessages.length === 0) return timelineMessages; + // Imported history is display-only — it never passed through the + // orchestration event log (see resumeSessionHistoryStore.ts) — so + // `turnId: null` degrades gracefully to plain unfolded rows, same as any + // other message without a live turn. + const importedChatMessages: ChatMessage[] = importedResumeHistoryMessages.map((message) => ({ + id: MessageId.make(message.id), + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + })); + return [...importedChatMessages, ...timelineMessages]; + }, [importedResumeHistoryMessages, timelineMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessagesWithImportedHistory, + activeThread?.proposedPlans ?? [], + workLogEntries, + ), + [activeThread?.proposedPlans, timelineMessagesWithImportedHistory, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = diff --git a/apps/web/src/components/chat/ResumeSessionDialog.tsx b/apps/web/src/components/chat/ResumeSessionDialog.tsx index e991affd3a381..e426fb3dccb27 100644 --- a/apps/web/src/components/chat/ResumeSessionDialog.tsx +++ b/apps/web/src/components/chat/ResumeSessionDialog.tsx @@ -17,10 +17,12 @@ import { getDefaultProviderInstanceModel, resolveSelectableProviderInstance, } from "~/providerInstances"; +import { useResumeSessionHistoryStore } from "~/resumeSessionHistoryStore"; import { useResumeSessionIntentStore } from "~/resumeSessionIntentStore"; import { useProjects } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { @@ -46,6 +48,13 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP const setResumeSessionIntent = useResumeSessionIntentStore( (store) => store.setResumeSessionIntent, ); + const setResumeSessionHistory = useResumeSessionHistoryStore( + (store) => store.setResumeSessionHistory, + ); + const getClaudeResumableSessionTranscript = useAtomCommand( + serverEnvironment.getClaudeResumableSessionTranscript, + { reportFailure: false }, + ); const [selectedProject, setSelectedProject] = useState(null); const [isResuming, setIsResuming] = useState(false); @@ -144,6 +153,20 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP resumeExternalSessionId: sessionId, label: session?.label ?? null, }); + // Best-effort: the resume itself (above) is already fully wired up + // server-side, so a failure fetching the display-only transcript + // here should never block navigating into the resumed draft. + const transcriptResult = await getClaudeResumableSessionTranscript({ + environmentId: selectedProject.environmentId, + input: { + workspaceRoot: selectedProject.workspaceRoot, + providerInstanceId: claudeInstanceId, + sessionId, + }, + }); + if (transcriptResult._tag === "Success") { + setResumeSessionHistory(threadId, transcriptResult.value.messages); + } await router.navigate({ to: "/draft/$draftId", params: { draftId } }); handleClose(false); } catch (error) { @@ -167,6 +190,8 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP router, sessions, setResumeSessionIntent, + setResumeSessionHistory, + getClaudeResumableSessionTranscript, handleClose, ], ); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index a2f39f99bef44..e797d3968d696 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -76,6 +76,7 @@ function createBrowserLocalApi(): LocalApi { getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), listClaudeResumableSessions: () => Promise.reject(unavailableLocalBackendError()), + getClaudeResumableSessionTranscript: () => Promise.reject(unavailableLocalBackendError()), signalProcess: () => Promise.reject(unavailableLocalBackendError()), }, }; diff --git a/apps/web/src/resumeSessionHistoryStore.ts b/apps/web/src/resumeSessionHistoryStore.ts new file mode 100644 index 0000000000000..3559efb2cb8ce --- /dev/null +++ b/apps/web/src/resumeSessionHistoryStore.ts @@ -0,0 +1,39 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; + +/** + * Ephemeral (non-persisted), display-only import of a resumed Claude Code + * session's past text messages, keyed by the T3 thread that's resuming it. + * These never become real orchestration events/messages — `ChatView` merges + * them into the rendered timeline ahead of the thread's own (live) messages, + * purely so the user can see what they're continuing. + * + * Deliberately a separate store from `resumeSessionIntentStore`: the intent + * (and its "Resuming a previous Claude session" banner) is cleared the + * moment the first message sends, but the imported history should keep + * rendering as part of the thread's visible context for as long as the + * thread is open. + */ +export interface ResumeSessionHistoryMessage { + readonly id: string; + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +interface ResumeSessionHistoryStoreState { + readonly historyByThreadId: Partial>>; + readonly setResumeSessionHistory: ( + threadId: ThreadId, + messages: ReadonlyArray, + ) => void; +} + +export const useResumeSessionHistoryStore = create((set) => ({ + historyByThreadId: {}, + setResumeSessionHistory: (threadId, messages) => { + set((state) => ({ + historyByThreadId: { ...state.historyByThreadId, [threadId]: messages }, + })); + }, +})); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 108c9e357ef3e..bc50246683b35 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -302,6 +302,10 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:list-claude-resumable-sessions", tag: WS_METHODS.serverListClaudeResumableSessions, }), + getClaudeResumableSessionTranscript: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:get-claude-resumable-session-transcript", + tag: WS_METHODS.serverGetClaudeResumableSessionTranscript, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index fadcf807a6621..3366c131090d3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -36,6 +36,8 @@ import type { ServerConfig, ServerListClaudeResumableSessionsInput, ServerListClaudeResumableSessionsResult, + ServerGetClaudeResumableSessionTranscriptInput, + ServerGetClaudeResumableSessionTranscriptResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, @@ -1159,6 +1161,9 @@ export interface LocalApi { listClaudeResumableSessions: ( input: ServerListClaudeResumableSessionsInput, ) => Promise; + getClaudeResumableSessionTranscript: ( + input: ServerGetClaudeResumableSessionTranscriptInput, + ) => Promise; signalProcess: (input: ServerSignalProcessInput) => Promise; }; } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f85586a0dde99..fb2a3ed214b9b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -131,6 +131,8 @@ import { ServerProcessResourceHistoryResult, ServerListClaudeResumableSessionsInput, ServerListClaudeResumableSessionsResult, + ServerGetClaudeResumableSessionTranscriptInput, + ServerGetClaudeResumableSessionTranscriptResult, ServerSignalProcessInput, ServerSignalProcessResult, ServerUpsertKeybindingInput, @@ -220,6 +222,7 @@ export const WS_METHODS = { serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverListClaudeResumableSessions: "server.listClaudeResumableSessions", + serverGetClaudeResumableSessionTranscript: "server.getClaudeResumableSessionTranscript", serverSignalProcess: "server.signalProcess", // Cloud environment methods @@ -340,6 +343,15 @@ export const WsServerListClaudeResumableSessionsRpc = Rpc.make( }, ); +export const WsServerGetClaudeResumableSessionTranscriptRpc = Rpc.make( + WS_METHODS.serverGetClaudeResumableSessionTranscript, + { + payload: ServerGetClaudeResumableSessionTranscriptInput, + success: ServerGetClaudeResumableSessionTranscriptResult, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -725,6 +737,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, WsServerListClaudeResumableSessionsRpc, + WsServerGetClaudeResumableSessionTranscriptRpc, WsServerSignalProcessRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0c217a69881ee..48cf7eff8a507 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -425,6 +425,36 @@ export const ServerListClaudeResumableSessionsResult = Schema.Struct({ export type ServerListClaudeResumableSessionsResult = typeof ServerListClaudeResumableSessionsResult.Type; +/** + * One historical text message read back from a resumable Claude Code + * session's on-disk transcript, for display-only import into T3's own + * thread view. Deliberately not a `OrchestrationMessage` — these never pass + * through the orchestration event log (see `ClaudeSessionHistory.ts`'s + * `getTranscript`); only text content is extracted (tool calls, thinking + * blocks, and attachments in the original transcript are dropped). + */ +export const ServerClaudeResumableSessionMessage = Schema.Struct({ + id: TrimmedNonEmptyString, + role: Schema.Literals(["user", "assistant"]), + text: TrimmedNonEmptyString, + createdAt: IsoDateTime, +}); +export type ServerClaudeResumableSessionMessage = typeof ServerClaudeResumableSessionMessage.Type; + +export const ServerGetClaudeResumableSessionTranscriptInput = Schema.Struct({ + workspaceRoot: TrimmedNonEmptyString, + providerInstanceId: Schema.optional(ProviderInstanceId), + sessionId: TrimmedNonEmptyString, +}); +export type ServerGetClaudeResumableSessionTranscriptInput = + typeof ServerGetClaudeResumableSessionTranscriptInput.Type; + +export const ServerGetClaudeResumableSessionTranscriptResult = Schema.Struct({ + messages: Schema.Array(ServerClaudeResumableSessionMessage), +}); +export type ServerGetClaudeResumableSessionTranscriptResult = + typeof ServerGetClaudeResumableSessionTranscriptResult.Type; + export const ServerSignalProcessInput = Schema.Struct({ pid: PositiveInt, signal: ServerProcessSignal, From 8224188a1780c20a92a942e96c67b5ff55de1d2a Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:31:12 +0200 Subject: [PATCH 6/9] feat: control Remote Control per-session, fix reviewer-flagged resume bugs Adds a Remote Control toggle chip to the composer for draft Claude threads, wired through a generalized bindSessionLaunchOptions (formerly bindResumeSession) that binds resume and/or remote-control choices together at thread-creation time. Also fixes the Remote Control settings field rendering as a text input instead of a switch, closes a path traversal hole in session id handling, fixes config dir resolution to use the workspace cwd, and fixes a double-click race and stale-draft-loss race in the resume dialog. --- .../src/provider/Layers/ClaudeAdapter.ts | 16 ++- .../provider/Layers/ClaudeSessionHistory.ts | 120 +++++++++++++----- apps/server/src/server.test.ts | 2 +- apps/server/src/ws.ts | 18 +-- apps/web/src/components/ChatView.tsx | 65 +++++++++- .../components/chat/ResumeSessionDialog.tsx | 38 +++++- apps/web/src/resumeSessionHistoryStore.ts | 9 ++ packages/contracts/src/orchestration.ts | 6 + packages/contracts/src/settings.ts | 3 + 9 files changed, 223 insertions(+), 54 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 20cf85e310f7f..26dc70487eb19 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -120,6 +120,12 @@ interface ClaudeResumeState { readonly resume?: string; readonly resumeSessionAt?: string; readonly turnCount?: number; + // Per-thread, decided once at thread creation (see + // ClaudeSessionHistory.bindSessionLaunchOptions) — not a resume concept, + // just piggybacking on the same opaque per-thread `resumeCursor` bag + // rather than growing ProviderSessionStartInput's cross-provider schema + // for a single-driver launch flag. + readonly remoteControl?: boolean; } interface ClaudeTurnState { @@ -565,6 +571,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef sessionId?: unknown; resumeSessionAt?: unknown; turnCount?: unknown; + remoteControl?: unknown; }; const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; @@ -582,6 +589,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef const resumeSessionAt = typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + const remoteControl = cursor.remoteControl === true ? true : undefined; return { ...(threadId ? { threadId } : {}), @@ -590,6 +598,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 ? { turnCount: turnCountValue } : {}), + ...(remoteControl ? { remoteControl } : {}), }; } @@ -3486,7 +3495,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = { ...parseCliArgs(claudeSettings.launchArgs).flags, - ...(claudeSettings.remoteControl ? { "remote-control": null } : {}), + // Either the per-instance persistent setting or a per-thread + // choice made when the thread was created (see + // ClaudeSessionHistory.bindSessionLaunchOptions) enables it. + ...(claudeSettings.remoteControl || resumeState?.remoteControl + ? { "remote-control": null } + : {}), }; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; diff --git a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts index 422d9bb139c71..b532d4d21013e 100644 --- a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts +++ b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts @@ -62,6 +62,17 @@ const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); const JSONL_EXTENSION = ".jsonl"; const MAX_LABEL_LENGTH = 80; const MAX_TRANSCRIPT_MESSAGES = 200; +// Every Claude Code session id observed on disk is a UUID (the filename +// stem of `.jsonl`). `sessionId` is caller-controlled input that +// gets interpolated into a filesystem path (`getTranscript`) — validating it +// against this shape before doing anything with it closes off path +// traversal (`../../other-project/secret`) rather than merely resolving to +// a possibly-out-of-bounds path and hoping the caller was honest. +const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidClaudeSessionId(sessionId: string): boolean { + return CLAUDE_SESSION_ID_PATTERN.test(sessionId); +} function encodeCwdForClaudeProjectDir(cwd: string): string { return cwd.replace(/[^a-zA-Z0-9]/g, "-"); @@ -220,10 +231,13 @@ function parseTranscriptMessages( return messages; } -export interface ClaudeSessionHistoryBindResumeSessionInput { +export interface ClaudeSessionHistoryBindSessionLaunchOptionsInput { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; - readonly resumeExternalSessionId: string; + /** Resume a picked on-disk session's model context via `--resume `. */ + readonly resumeExternalSessionId?: string; + /** Start the session with Claude Code's Remote Control (`--remote-control`) enabled. */ + readonly remoteControl?: boolean; } export class ClaudeSessionHistory extends Context.Service< @@ -242,12 +256,14 @@ export class ClaudeSessionHistory extends Context.Service< input: ServerGetClaudeResumableSessionTranscriptInput, ) => Effect.Effect; /** - * Binds a picked on-disk Claude session to a thread's provider session - * directory entry, so the next turn resumes it via `--resume `. - * Fails if the target provider instance isn't a Claude instance. + * Binds thread-creation-time Claude launch choices (resume a picked + * on-disk session, and/or start with Remote Control enabled) onto a + * thread's provider session directory entry, so they take effect on the + * next (first) turn. No-op if neither option is set. Fails if the + * target provider instance isn't a Claude instance. */ - readonly bindResumeSession: ( - input: ClaudeSessionHistoryBindResumeSessionInput, + readonly bindSessionLaunchOptions: ( + input: ClaudeSessionHistoryBindSessionLaunchOptionsInput, ) => Effect.Effect; } >()("t3/provider/Layers/ClaudeSessionHistory") {} @@ -266,12 +282,15 @@ export const make = Effect.gen(function* () { // rather than failing the whole listing — best-effort, matching `list`'s // other file-not-found handling. const resolveConfigDirPathForInstance = Effect.fn("ClaudeSessionHistory.resolveConfigDirPath")( - function* (providerInstanceId: ProviderInstanceId | undefined) { + function* (input: { + readonly providerInstanceId: ProviderInstanceId | undefined; + readonly workspaceRoot: string; + }) { const homePath = yield* Effect.gen(function* () { - if (providerInstanceId === undefined) return ""; + if (input.providerInstanceId === undefined) return ""; const settings = yield* serverSettings.getSettings; const configMap = deriveProviderInstanceConfigMap(settings); - const entry = configMap[providerInstanceId]; + const entry = configMap[input.providerInstanceId]; const decoded = yield* decodeClaudeSettings(entry?.config ?? {}); return decoded.homePath; }).pipe(Effect.orElseSucceed(() => "")); @@ -279,7 +298,11 @@ export const make = Effect.gen(function* () { // satisfy it from the already-resolved `path` in scope so this stays // free of a `Path.Path` requirement of its own (see the `list`/`make` // split above — required for `Layer.provideMerge` to fully resolve it). - return yield* resolveClaudeConfigDirPath({ homePath }, process.env).pipe( + // `workspaceRoot` is passed as `cwd`: when `homePath` is unset and a + // *relative* `CLAUDE_CONFIG_DIR` is set in the environment, it must be + // resolved the same way the spawned `claude` subprocess itself + // resolves it — against the workspace cwd, not the server's own cwd. + return yield* resolveClaudeConfigDirPath({ homePath }, process.env, input.workspaceRoot).pipe( Effect.provideService(Path.Path, path), ); }, @@ -290,7 +313,10 @@ export const make = Effect.gen(function* () { readonly workspaceRoot: string; readonly providerInstanceId?: ProviderInstanceId | undefined; }) { - const configDirPath = yield* resolveConfigDirPathForInstance(input.providerInstanceId); + const configDirPath = yield* resolveConfigDirPathForInstance({ + providerInstanceId: input.providerInstanceId, + workspaceRoot: input.workspaceRoot, + }); return path.join( configDirPath, "projects", @@ -350,6 +376,16 @@ export const make = Effect.gen(function* () { const getTranscript: ClaudeSessionHistory["Service"]["getTranscript"] = Effect.fn( "ClaudeSessionHistory.getTranscript", )(function* (input) { + // Best-effort/display-only endpoint (see class doc), so an invalid + // sessionId — most importantly one crafted to escape `projectDirPath` + // via `../` traversal segments — degrades to "no history to show" + // rather than a hard error, matching this file's other not-found + // handling. The validation itself is still load-bearing: it's what + // stops `path.join` below from ever being handed a value that resolves + // outside `projectDirPath`. + if (!isValidClaudeSessionId(input.sessionId)) { + return { messages: [] }; + } const projectDirPath = yield* resolveProjectDirPath(input); const filePath = path.join(projectDirPath, `${input.sessionId}${JSONL_EXTENSION}`); const raw = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); @@ -362,31 +398,47 @@ export const make = Effect.gen(function* () { return { messages: trimmedMessages }; }); - const bindResumeSession: ClaudeSessionHistory["Service"]["bindResumeSession"] = Effect.fn( - "ClaudeSessionHistory.bindResumeSession", - )(function* (input) { - const instance = yield* providerInstanceRegistry.getInstance(input.providerInstanceId); - if (!instance) { - return yield* new ProviderValidationError({ - operation: "ClaudeSessionHistory.bindResumeSession", - issue: `Provider instance '${input.providerInstanceId}' is not configured.`, - }); - } - if (instance.driverKind !== CLAUDE_PROVIDER) { - return yield* new ProviderValidationError({ - operation: "ClaudeSessionHistory.bindResumeSession", - issue: `resumeExternalSessionId is only supported for Claude Code provider instances; '${input.providerInstanceId}' is a '${instance.driverKind}' instance.`, + const bindSessionLaunchOptions: ClaudeSessionHistory["Service"]["bindSessionLaunchOptions"] = + Effect.fn("ClaudeSessionHistory.bindSessionLaunchOptions")(function* (input) { + if (input.resumeExternalSessionId === undefined && !input.remoteControl) { + return; + } + if ( + input.resumeExternalSessionId !== undefined && + !isValidClaudeSessionId(input.resumeExternalSessionId) + ) { + return yield* new ProviderValidationError({ + operation: "ClaudeSessionHistory.bindSessionLaunchOptions", + issue: `resumeExternalSessionId must be a Claude session UUID; received '${input.resumeExternalSessionId}'.`, + }); + } + const instance = yield* providerInstanceRegistry.getInstance(input.providerInstanceId); + if (!instance) { + return yield* new ProviderValidationError({ + operation: "ClaudeSessionHistory.bindSessionLaunchOptions", + issue: `Provider instance '${input.providerInstanceId}' is not configured.`, + }); + } + if (instance.driverKind !== CLAUDE_PROVIDER) { + return yield* new ProviderValidationError({ + operation: "ClaudeSessionHistory.bindSessionLaunchOptions", + issue: `Resume/Remote Control are only supported for Claude Code provider instances; '${input.providerInstanceId}' is a '${instance.driverKind}' instance.`, + }); + } + yield* providerSessionDirectory.upsert({ + threadId: input.threadId, + provider: instance.driverKind, + providerInstanceId: input.providerInstanceId, + resumeCursor: { + ...(input.resumeExternalSessionId !== undefined + ? { resume: input.resumeExternalSessionId } + : {}), + ...(input.remoteControl ? { remoteControl: true } : {}), + }, }); - } - yield* providerSessionDirectory.upsert({ - threadId: input.threadId, - provider: instance.driverKind, - providerInstanceId: input.providerInstanceId, - resumeCursor: { resume: input.resumeExternalSessionId }, }); - }); - return ClaudeSessionHistory.of({ list, getTranscript, bindResumeSession }); + return ClaudeSessionHistory.of({ list, getTranscript, bindSessionLaunchOptions }); }); export const layer = Layer.effect(ClaudeSessionHistory, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 9a2a0ef65b368..3892df931f4ad 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -613,7 +613,7 @@ const buildAppUnderTest = (options?: { Layer.mock(ClaudeSessionHistory.ClaudeSessionHistory)({ list: () => Effect.succeed({ sessions: [] }), getTranscript: () => Effect.succeed({ messages: [] }), - bindResumeSession: () => Effect.void, + bindSessionLaunchOptions: () => Effect.void, }), ), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8f389eea69240..6ad04a9067ab3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -998,14 +998,16 @@ const makeWsRpcLayer = ( }); createdThread = true; - const resumeExternalSessionId = bootstrap.createThread.resumeExternalSessionId; - if (resumeExternalSessionId) { - yield* claudeSessionHistory.bindResumeSession({ - threadId: command.threadId, - providerInstanceId: bootstrap.createThread.modelSelection.instanceId, - resumeExternalSessionId, - }); - } + yield* claudeSessionHistory.bindSessionLaunchOptions({ + threadId: command.threadId, + providerInstanceId: bootstrap.createThread.modelSelection.instanceId, + ...(bootstrap.createThread.resumeExternalSessionId !== undefined + ? { resumeExternalSessionId: bootstrap.createThread.resumeExternalSessionId } + : {}), + ...(bootstrap.createThread.remoteControl + ? { remoteControl: bootstrap.createThread.remoteControl } + : {}), + }); } if (bootstrap?.prepareWorktree) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index adb0aeaae6d2c..2845c3453b283 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -145,6 +145,7 @@ import { ChevronDownIcon, GitBranchIcon, HistoryIcon, + RadioTowerIcon, TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; @@ -238,6 +239,7 @@ import { import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { Switch } from "./ui/switch"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, @@ -1287,6 +1289,11 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); + // Remote Control can only be decided once, at thread creation — it's a + // launch flag for the session's underlying `claude` process, not + // something that can be toggled after it's already running. Reset + // whenever the viewed thread changes so it never leaks between drafts. + const [draftRemoteControlEnabled, setDraftRemoteControlEnabled] = useState(false); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); const [ pendingServerThreadStartFromOriginByThreadId, @@ -4130,6 +4137,19 @@ function ChatViewContent(props: ChatViewProps) { const pendingResumeSessionIntent = useResumeSessionIntentStore((store) => isLocalDraftThread && activeThread ? (store.intentByThreadId[activeThread.id] ?? null) : null, ); + const cancelResumeSessionIntent = useCallback((draftThreadId: ThreadId) => { + // Deliberately NOT wired through ComposerBannerStack's `onDismiss` + // (dismissLabel/onDismiss): that callback is deferred ~220ms behind an + // exit animation, and this specific banner's host `ComposerBannerStack` + // instance can unmount mid-animation when a resume draft docks out of + // hero layout on send — which drops the deferred callback entirely, so + // a fast cancel-then-send could still resume. Clearing both stores here, + // synchronously, from a plain button click closes that race: this + // `useMemo` reacts to the store instantly and the banner just disappears + // (no animation needed for correctness, only for polish). + useResumeSessionIntentStore.getState().clearResumeSessionIntent(draftThreadId); + useResumeSessionHistoryStore.getState().clearResumeSessionHistory(draftThreadId); + }, []); const resumeSessionBannerItem = useMemo(() => { if (!pendingResumeSessionIntent || !isLocalDraftThread || !activeThread) return null; const draftThreadId = activeThread.id; @@ -4139,21 +4159,48 @@ function ChatViewContent(props: ChatViewProps) { icon: , title: "Resuming a previous Claude session", description: pendingResumeSessionIntent.label ?? undefined, - dismissLabel: "Cancel resume", - onDismiss: () => { - useResumeSessionIntentStore.getState().clearResumeSessionIntent(draftThreadId); - }, + actions: ( + + ), + }; + }, [pendingResumeSessionIntent, isLocalDraftThread, activeThread, cancelResumeSessionIntent]); + const canOfferRemoteControlToggle = + isLocalDraftThread && selectedProvider === ProviderDriverKind.make("claudeAgent"); + const remoteControlBannerItem = useMemo(() => { + if (!canOfferRemoteControlToggle) return null; + return { + id: "draft-remote-control", + variant: "info", + icon: , + title: "Remote Control", + description: "Attach to this session from claude.ai or the Claude mobile app once it starts.", + actions: ( + setDraftRemoteControlEnabled(Boolean(checked))} + aria-label="Enable Remote Control for this session" + /> + ), }; - }, [pendingResumeSessionIntent, isLocalDraftThread, activeThread]); + }, [canOfferRemoteControlToggle, draftRemoteControlEnabled]); const composerBannerItems = useMemo(() => { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; const resumeSessionItems = resumeSessionBannerItem === null ? [] : [resumeSessionBannerItem]; + const remoteControlItems = remoteControlBannerItem === null ? [] : [remoteControlBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [...systemComposerBannerItems, ...resumeSessionItems, ...parkedThreadItems]; + return [ + ...systemComposerBannerItems, + ...resumeSessionItems, + ...remoteControlItems, + ...parkedThreadItems, + ]; } return [ ...systemComposerBannerItems, ...resumeSessionItems, + ...remoteControlItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", @@ -4198,6 +4245,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeBranchMismatchKey, resumeSessionBannerItem, + remoteControlBannerItem, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, @@ -4211,6 +4259,10 @@ function ChatViewContent(props: ChatViewProps) { setPendingServerThreadBranch(undefined); }, [activeThread?.id]); + useEffect(() => { + setDraftRemoteControlEnabled(false); + }, [activeThread?.id]); + useEffect(() => { if (canOverrideServerThreadEnvMode) { return; @@ -4791,6 +4843,7 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, ...(resumeExternalSessionId ? { resumeExternalSessionId } : {}), + ...(draftRemoteControlEnabled ? { remoteControl: true } : {}), }, } : {}), diff --git a/apps/web/src/components/chat/ResumeSessionDialog.tsx b/apps/web/src/components/chat/ResumeSessionDialog.tsx index e426fb3dccb27..8adcdb17f48e0 100644 --- a/apps/web/src/components/chat/ResumeSessionDialog.tsx +++ b/apps/web/src/components/chat/ResumeSessionDialog.tsx @@ -4,7 +4,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { ProviderDriverKind } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { ChevronLeftIcon } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; import { useClientSettings } from "~/hooks/useSettings"; @@ -57,6 +57,11 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP ); const [selectedProject, setSelectedProject] = useState(null); const [isResuming, setIsResuming] = useState(false); + // `isResuming` state isn't visible synchronously to the same tick a second + // click would fire in (React batches the update), so a fast double-click + // could pass the state check twice and kick off two competing resumes. A + // ref is updated (and read) synchronously, closing that race. + const isResumingRef = useRef(false); const sortedProjects = useMemo( () => [...projects].sort((left, right) => left.title.localeCompare(right.title)), @@ -66,6 +71,7 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP const serverConfig = useAtomValue( serverEnvironment.configValueAtom(selectedProject?.environmentId ?? null), ); + const isProviderConfigPending = selectedProject !== null && serverConfig === null; const claudeProviders = useMemo( () => (serverConfig?.providers ?? []).filter((provider) => provider.driver === CLAUDE_DRIVER), [serverConfig], @@ -115,7 +121,8 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP const handleResume = useCallback( async (sessionId: string) => { - if (!selectedProject || !claudeInstanceId || !claudeModel || isResuming) return; + if (!selectedProject || !claudeInstanceId || !claudeModel || isResumingRef.current) return; + isResumingRef.current = true; setIsResuming(true); try { const projectRef = scopeProjectRef(selectedProject.environmentId, selectedProject.id); @@ -123,6 +130,25 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP selectedProject, projectGroupingSettings, ); + const { getComposerDraft, getDraftThreadByProjectRef } = useComposerDraftStore.getState(); + const existingDraft = getDraftThreadByProjectRef(projectRef); + if (existingDraft) { + const existingComposerState = getComposerDraft(existingDraft.draftId); + const hasUnsentContent = + (existingComposerState?.prompt.trim().length ?? 0) > 0 || + (existingComposerState?.images.length ?? 0) > 0 || + (existingComposerState?.persistedAttachments.length ?? 0) > 0; + if (hasUnsentContent) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "You have an unsent draft for this project", + description: "Send or clear it before resuming a different Claude session.", + }), + ); + return; + } + } const draftId = newDraftId(); const threadId = newThreadId(); const { setLogicalProjectDraftThreadId, setModelSelection } = @@ -178,6 +204,7 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP }), ); } finally { + isResumingRef.current = false; setIsResuming(false); } }, @@ -185,7 +212,6 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP selectedProject, claudeInstanceId, claudeModel, - isResuming, projectGroupingSettings, router, sessions, @@ -240,7 +266,11 @@ export function ResumeSessionDialog({ open, onOpenChange }: ResumeSessionDialogP Back to projects - {!claudeInstanceId || !claudeModel ? ( + {isProviderConfigPending ? ( +

+ Loading provider configuration… +

+ ) : !claudeInstanceId || !claudeModel ? (

No Claude Code provider is configured for this environment.

diff --git a/apps/web/src/resumeSessionHistoryStore.ts b/apps/web/src/resumeSessionHistoryStore.ts index 3559efb2cb8ce..578534c2836ee 100644 --- a/apps/web/src/resumeSessionHistoryStore.ts +++ b/apps/web/src/resumeSessionHistoryStore.ts @@ -27,6 +27,7 @@ interface ResumeSessionHistoryStoreState { threadId: ThreadId, messages: ReadonlyArray, ) => void; + readonly clearResumeSessionHistory: (threadId: ThreadId) => void; } export const useResumeSessionHistoryStore = create((set) => ({ @@ -36,4 +37,12 @@ export const useResumeSessionHistoryStore = create { + set((state) => { + if (!(threadId in state.historyByThreadId)) return state; + const next = { ...state.historyByThreadId }; + delete next[threadId]; + return { historyByThreadId: next }; + }); + }, })); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1af08d76e8a5a..056a8317d66d7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -656,6 +656,12 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ // because only Claude's on-disk transcript format is verified; other // providers' resume cursors are not derived from this field. resumeExternalSessionId: Schema.optional(TrimmedNonEmptyString), + // Claude-only: start this brand-new thread's session with Claude Code's + // Remote Control enabled (`--remote-control`), bound the same way and at + // the same point as `resumeExternalSessionId` above (both are decided + // once, at thread-creation time, and can't be changed after the session + // has started). + remoteControl: Schema.optional(Schema.Boolean), }); const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4794c8d3015e8..195c48cb0f767 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -279,6 +279,9 @@ export const ClaudeSettings = makeProviderSettingsSchema( title: "Remote Control", description: "Start sessions with Claude Code's Remote Control enabled (--remote-control), so you can attach from claude.ai or the Claude mobile app.", + providerSettingsForm: { + control: "switch", + }, }), ), }, From a7b4ef005b982836c680eee16524c49d370cc23c Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:08:02 +0200 Subject: [PATCH 7/9] feat: control Remote Control per-thread from the composer toolbar Moves the Remote Control toggle out of the composer banner and into the toolbar next to the Build/Plan switch, and extends it to already-created threads (not just new drafts) via a new setClaudeThreadRemoteControl RPC. Since Remote Control is a claude process launch flag, applying it to a thread with an active session stops that session so it relaunches with the flag on its next turn. Also adds a dismiss control to the "resuming a previous session" banner so it can be hidden without cancelling the resume, and fixes bindSessionLaunchOptions to merge onto an existing resumeCursor instead of overwriting it (so setting remoteControl on a thread that already has a resume cursor doesn't strand its continuity). --- .../provider/Layers/ClaudeSessionHistory.ts | 65 +++++++++- apps/server/src/server.test.ts | 1 + apps/server/src/ws.ts | 9 ++ apps/web/src/components/ChatView.tsx | 121 ++++++++++++------ apps/web/src/components/chat/ChatComposer.tsx | 59 +++++++++ .../chat/CompactComposerControlsMenu.tsx | 23 +++- apps/web/src/localApi.ts | 1 + packages/client-runtime/src/state/server.ts | 4 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/rpc.ts | 13 ++ packages/contracts/src/server.ts | 23 ++++ 11 files changed, 281 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts index b532d4d21013e..c697471d29d67 100644 --- a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts +++ b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts @@ -37,6 +37,8 @@ import { type ServerGetClaudeResumableSessionTranscriptResult, type ServerListClaudeResumableSessionsInput, type ServerListClaudeResumableSessionsResult, + type ServerSetClaudeThreadRemoteControlInput, + type ServerSetClaudeThreadRemoteControlResult, type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -53,6 +55,7 @@ import { resolveClaudeConfigDirPath } from "../Drivers/ClaudeSkills.ts"; import { ProviderValidationError } from "../Errors.ts"; import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; import type { ProviderSessionDirectoryWriteError } from "../Services/ProviderSessionDirectory.ts"; @@ -236,7 +239,11 @@ export interface ClaudeSessionHistoryBindSessionLaunchOptionsInput { readonly providerInstanceId: ProviderInstanceId; /** Resume a picked on-disk session's model context via `--resume `. */ readonly resumeExternalSessionId?: string; - /** Start the session with Claude Code's Remote Control (`--remote-control`) enabled. */ + /** + * Start the session with Claude Code's Remote Control (`--remote-control`) + * enabled, or explicitly disabled. `undefined` leaves whatever was + * previously bound untouched (e.g. a plain resume-only call). + */ readonly remoteControl?: boolean; } @@ -265,6 +272,20 @@ export class ClaudeSessionHistory extends Context.Service< readonly bindSessionLaunchOptions: ( input: ClaudeSessionHistoryBindSessionLaunchOptionsInput, ) => Effect.Effect; + /** + * Turns Remote Control on/off for an already-created thread. Unlike + * `bindSessionLaunchOptions` (thread-creation time only), this can run + * against a thread with an active running session — Remote Control is a + * `claude` process launch flag, not something a running process can be + * told to flip live, so this stops the thread's active session (if any) + * after binding the choice, so it relaunches with the flag applied on + * its next turn. Best-effort/non-fatal: `applied: false` on a bind + * failure (e.g. non-Claude instance); a stop failure or no active + * session never fails the call, since the bind itself already took. + */ + readonly setThreadRemoteControl: ( + input: ServerSetClaudeThreadRemoteControlInput, + ) => Effect.Effect; } >()("t3/provider/Layers/ClaudeSessionHistory") {} @@ -273,6 +294,7 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const providerInstanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const providerService = yield* ProviderService; const serverSettings = yield* ServerSettingsService; // Claude instances can be configured with a custom `homePath` (keeping @@ -400,7 +422,7 @@ export const make = Effect.gen(function* () { const bindSessionLaunchOptions: ClaudeSessionHistory["Service"]["bindSessionLaunchOptions"] = Effect.fn("ClaudeSessionHistory.bindSessionLaunchOptions")(function* (input) { - if (input.resumeExternalSessionId === undefined && !input.remoteControl) { + if (input.resumeExternalSessionId === undefined && input.remoteControl === undefined) { return; } if ( @@ -425,20 +447,55 @@ export const make = Effect.gen(function* () { issue: `Resume/Remote Control are only supported for Claude Code provider instances; '${input.providerInstanceId}' is a '${instance.driverKind}' instance.`, }); } + // Merge onto whatever's already bound (rather than replacing + // wholesale) — this can now be called on a thread that already has a + // `resume` cursor from a prior turn (e.g. toggling Remote Control on + // an existing, already-running thread), and clobbering that would + // strand the thread's actual Claude session continuity. + const existingBinding = yield* providerSessionDirectory.getBinding(input.threadId); + const existingResumeCursor = Option.getOrUndefined(existingBinding)?.resumeCursor; + const existingResumeCursorFields = + existingResumeCursor !== null && + typeof existingResumeCursor === "object" && + existingResumeCursor !== undefined + ? (existingResumeCursor as Record) + : {}; yield* providerSessionDirectory.upsert({ threadId: input.threadId, provider: instance.driverKind, providerInstanceId: input.providerInstanceId, resumeCursor: { + ...existingResumeCursorFields, ...(input.resumeExternalSessionId !== undefined ? { resume: input.resumeExternalSessionId } : {}), - ...(input.remoteControl ? { remoteControl: true } : {}), + ...(input.remoteControl !== undefined ? { remoteControl: input.remoteControl } : {}), }, }); }); - return ClaudeSessionHistory.of({ list, getTranscript, bindSessionLaunchOptions }); + const setThreadRemoteControl: ClaudeSessionHistory["Service"]["setThreadRemoteControl"] = + Effect.fn("ClaudeSessionHistory.setThreadRemoteControl")(function* (input) { + const bindResult = yield* bindSessionLaunchOptions({ + threadId: input.threadId, + providerInstanceId: input.providerInstanceId, + remoteControl: input.enabled, + }).pipe(Effect.result); + if (bindResult._tag === "Failure") { + return { applied: false }; + } + yield* providerService + .stopSession({ threadId: input.threadId }) + .pipe(Effect.catch(() => Effect.void)); + return { applied: true }; + }); + + return ClaudeSessionHistory.of({ + list, + getTranscript, + bindSessionLaunchOptions, + setThreadRemoteControl, + }); }); export const layer = Layer.effect(ClaudeSessionHistory, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3892df931f4ad..230d3138c0249 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -614,6 +614,7 @@ const buildAppUnderTest = (options?: { list: () => Effect.succeed({ sessions: [] }), getTranscript: () => Effect.succeed({ messages: [] }), bindSessionLaunchOptions: () => Effect.void, + setThreadRemoteControl: () => Effect.succeed({ applied: true }), }), ), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6ad04a9067ab3..3ac1cb5821891 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -309,6 +309,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverListClaudeResumableSessions, AuthOrchestrationReadScope], [WS_METHODS.serverGetClaudeResumableSessionTranscript, AuthOrchestrationReadScope], + [WS_METHODS.serverSetClaudeThreadRemoteControl, AuthOrchestrationOperateScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], @@ -1583,6 +1584,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverSetClaudeThreadRemoteControl]: (input) => + observeRpcEffect( + WS_METHODS.serverSetClaudeThreadRemoteControl, + claudeSessionHistory.setThreadRemoteControl(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverSignalProcess]: (input) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2845c3453b283..703c5eea3efd7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -145,7 +145,6 @@ import { ChevronDownIcon, GitBranchIcon, HistoryIcon, - RadioTowerIcon, TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; @@ -239,7 +238,6 @@ import { import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; -import { Switch } from "./ui/switch"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, @@ -1143,6 +1141,10 @@ function ChatViewContent(props: ChatViewProps) { const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); + const setClaudeThreadRemoteControl = useAtomCommand( + serverEnvironment.setClaudeThreadRemoteControl, + { reportFailure: false }, + ); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -1289,11 +1291,17 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); - // Remote Control can only be decided once, at thread creation — it's a - // launch flag for the session's underlying `claude` process, not - // something that can be toggled after it's already running. Reset - // whenever the viewed thread changes so it never leaks between drafts. - const [draftRemoteControlEnabled, setDraftRemoteControlEnabled] = useState(false); + // Remote Control is a launch flag for the session's underlying `claude` + // process, not something a running process can be told to flip live. For + // a draft thread this is just picked up at send time (see + // `bootstrap.createThread.remoteControl` below). For an existing thread + // it's applied via `setClaudeThreadRemoteControl`, which stops the + // thread's active session so it relaunches with the flag on its next + // turn. Local display state only — reset per thread since there's no + // read-path yet for whatever's actually bound server-side. + const [remoteControlEnabled, setRemoteControlEnabled] = useState(false); + const [remoteControlPending, setRemoteControlPending] = useState(false); + const [resumeBannerHidden, setResumeBannerHidden] = useState(false); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); const [ pendingServerThreadStartFromOriginByThreadId, @@ -4134,6 +4142,45 @@ function ChatViewContent(props: ChatViewProps) { } void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); + const handleToggleRemoteControl = useCallback( + async (nextEnabled: boolean) => { + setRemoteControlEnabled(nextEnabled); + if (isLocalDraftThread || !activeThread) { + // Draft threads only pick this up at send time (see + // `bootstrap.createThread.remoteControl`) — nothing to call yet. + return; + } + setRemoteControlPending(true); + const result = await setClaudeThreadRemoteControl({ + environmentId, + input: { + threadId: activeThread.id, + providerInstanceId: activeThread.modelSelection.instanceId, + enabled: nextEnabled, + }, + }); + setRemoteControlPending(false); + if (result._tag === "Failure") { + setRemoteControlEnabled(!nextEnabled); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't update Remote Control", + description: "Something went wrong applying the change. Try again.", + }), + ); + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: nextEnabled ? "Remote Control enabled" : "Remote Control disabled", + description: "Applies the next time this thread's session starts.", + }), + ); + }, + [isLocalDraftThread, activeThread, environmentId, setClaudeThreadRemoteControl], + ); const pendingResumeSessionIntent = useResumeSessionIntentStore((store) => isLocalDraftThread && activeThread ? (store.intentByThreadId[activeThread.id] ?? null) : null, ); @@ -4152,6 +4199,7 @@ function ChatViewContent(props: ChatViewProps) { }, []); const resumeSessionBannerItem = useMemo(() => { if (!pendingResumeSessionIntent || !isLocalDraftThread || !activeThread) return null; + if (resumeBannerHidden) return null; const draftThreadId = activeThread.id; return { id: `resume-session:${pendingResumeSessionIntent.resumeExternalSessionId}`, @@ -4164,43 +4212,31 @@ function ChatViewContent(props: ChatViewProps) { Cancel ), + // Hides the banner without cancelling the resume — the resume still + // happens on send. Kept separate from the `actions` Cancel button + // above (which does cancel) via the same deferred-animation path + // already used for the branch-mismatch banner below: unlike Cancel, + // hiding isn't correctness-critical, so the 220ms dismiss race is + // harmless here. + dismissLabel: "Hide (still resumes on send)", + onDismiss: () => setResumeBannerHidden(true), }; - }, [pendingResumeSessionIntent, isLocalDraftThread, activeThread, cancelResumeSessionIntent]); - const canOfferRemoteControlToggle = - isLocalDraftThread && selectedProvider === ProviderDriverKind.make("claudeAgent"); - const remoteControlBannerItem = useMemo(() => { - if (!canOfferRemoteControlToggle) return null; - return { - id: "draft-remote-control", - variant: "info", - icon: , - title: "Remote Control", - description: "Attach to this session from claude.ai or the Claude mobile app once it starts.", - actions: ( - setDraftRemoteControlEnabled(Boolean(checked))} - aria-label="Enable Remote Control for this session" - /> - ), - }; - }, [canOfferRemoteControlToggle, draftRemoteControlEnabled]); + }, [ + pendingResumeSessionIntent, + isLocalDraftThread, + activeThread, + resumeBannerHidden, + cancelResumeSessionIntent, + ]); const composerBannerItems = useMemo(() => { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; const resumeSessionItems = resumeSessionBannerItem === null ? [] : [resumeSessionBannerItem]; - const remoteControlItems = remoteControlBannerItem === null ? [] : [remoteControlBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [ - ...systemComposerBannerItems, - ...resumeSessionItems, - ...remoteControlItems, - ...parkedThreadItems, - ]; + return [...systemComposerBannerItems, ...resumeSessionItems, ...parkedThreadItems]; } return [ ...systemComposerBannerItems, ...resumeSessionItems, - ...remoteControlItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", @@ -4245,7 +4281,6 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeBranchMismatchKey, resumeSessionBannerItem, - remoteControlBannerItem, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, @@ -4260,7 +4295,11 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThread?.id]); useEffect(() => { - setDraftRemoteControlEnabled(false); + setRemoteControlEnabled(false); + }, [activeThread?.id]); + + useEffect(() => { + setResumeBannerHidden(false); }, [activeThread?.id]); useEffect(() => { @@ -4843,7 +4882,7 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, ...(resumeExternalSessionId ? { resumeExternalSessionId } : {}), - ...(draftRemoteControlEnabled ? { remoteControl: true } : {}), + ...(remoteControlEnabled ? { remoteControl: true } : {}), }, } : {}), @@ -5933,6 +5972,12 @@ function ChatViewContent(props: ChatViewProps) { planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} interactionMode={interactionMode} + showRemoteControlToggle={ + selectedProvider === ProviderDriverKind.make("claudeAgent") + } + remoteControlEnabled={remoteControlEnabled} + remoteControlPending={remoteControlPending} + onToggleRemoteControl={handleToggleRemoteControl} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} activeProjectDefaultModelSelection={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7ac..e3f13b968b717 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -163,6 +163,7 @@ import { LockIcon, LockOpenIcon, PenLineIcon, + RadioTowerIcon, SparklesIcon, XIcon, } from "lucide-react"; @@ -267,9 +268,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showPlanToggle: boolean; planSidebarLabel: string; planSidebarOpen: boolean; + showRemoteControlToggle: boolean; + remoteControlEnabled: boolean; + remoteControlPending: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; + onToggleRemoteControl: (enabled: boolean) => void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -395,6 +400,42 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop ) : null} + + {props.showRemoteControlToggle ? ( + <> + + + props.onToggleRemoteControl(!props.remoteControlEnabled)} + aria-label={ + props.remoteControlEnabled ? "Disable Remote Control" : "Enable Remote Control" + } + /> + } + > + + Remote Control + + + Attach to this session from claude.ai or the Claude mobile app once it starts. + + + + ) : null} ); }); @@ -556,6 +597,11 @@ export interface ChatComposerProps { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; + // Remote Control + showRemoteControlToggle: boolean; + remoteControlEnabled: boolean; + remoteControlPending: boolean; + // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; @@ -604,6 +650,7 @@ export interface ChatComposerProps { handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; togglePlanSidebar: () => void; + onToggleRemoteControl: (enabled: boolean) => void; focusComposer: () => void; scheduleComposerFocus: () => void; @@ -651,6 +698,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planSidebarOpen, runtimeMode, interactionMode, + showRemoteControlToggle, + remoteControlEnabled, + remoteControlPending, lockedProvider, providerStatuses, activeProjectDefaultModelSelection, @@ -680,6 +730,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) handleRuntimeModeChange, handleInteractionModeChange, togglePlanSidebar, + onToggleRemoteControl, focusComposer, scheduleComposerFocus, setThreadError, @@ -2679,10 +2730,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} + showRemoteControlToggle={showRemoteControlToggle} + remoteControlEnabled={remoteControlEnabled} + remoteControlPending={remoteControlPending} traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onRuntimeModeChange={handleRuntimeModeChange} + onToggleRemoteControl={onToggleRemoteControl} /> ) : ( <> @@ -2699,9 +2754,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanToggle={showPlanSidebarToggle} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} + showRemoteControlToggle={showRemoteControlToggle} + remoteControlEnabled={remoteControlEnabled} + remoteControlPending={remoteControlPending} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} onTogglePlanSidebar={togglePlanSidebar} + onToggleRemoteControl={onToggleRemoteControl} /> )} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index b808f56292016..91445df79cdfc 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,9 +1,10 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { EllipsisIcon, ListTodoIcon, RadioTowerIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuItem, MenuPopup, MenuRadioGroup, @@ -19,10 +20,14 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls planSidebarOpen: boolean; runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; + showRemoteControlToggle: boolean; + remoteControlEnabled: boolean; + remoteControlPending: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; + onToggleRemoteControl: (enabled: boolean) => void; }) { return ( @@ -85,6 +90,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls ) : null} + {props.showRemoteControlToggle ? ( + <> + + props.onToggleRemoteControl(Boolean(checked))} + > + + + Remote Control + + + + ) : null} ); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index e797d3968d696..2b35081be19a2 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -77,6 +77,7 @@ function createBrowserLocalApi(): LocalApi { getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), listClaudeResumableSessions: () => Promise.reject(unavailableLocalBackendError()), getClaudeResumableSessionTranscript: () => Promise.reject(unavailableLocalBackendError()), + setClaudeThreadRemoteControl: () => Promise.reject(unavailableLocalBackendError()), signalProcess: () => Promise.reject(unavailableLocalBackendError()), }, }; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index bc50246683b35..1269b04d40b19 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -306,6 +306,10 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:get-claude-resumable-session-transcript", tag: WS_METHODS.serverGetClaudeResumableSessionTranscript, }), + setClaudeThreadRemoteControl: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:set-claude-thread-remote-control", + tag: WS_METHODS.serverSetClaudeThreadRemoteControl, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3366c131090d3..8475bba05137b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -44,6 +44,8 @@ import type { ServerProviderUpdateInput, ServerProviderUpdatedPayload, ServerRemoveKeybindingResult, + ServerSetClaudeThreadRemoteControlInput, + ServerSetClaudeThreadRemoteControlResult, ServerSignalProcessInput, ServerSignalProcessResult, ServerTraceDiagnosticsResult, @@ -1164,6 +1166,9 @@ export interface LocalApi { getClaudeResumableSessionTranscript: ( input: ServerGetClaudeResumableSessionTranscriptInput, ) => Promise; + setClaudeThreadRemoteControl: ( + input: ServerSetClaudeThreadRemoteControlInput, + ) => Promise; signalProcess: (input: ServerSignalProcessInput) => Promise; }; } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fb2a3ed214b9b..aa7321da34cbb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -133,6 +133,8 @@ import { ServerListClaudeResumableSessionsResult, ServerGetClaudeResumableSessionTranscriptInput, ServerGetClaudeResumableSessionTranscriptResult, + ServerSetClaudeThreadRemoteControlInput, + ServerSetClaudeThreadRemoteControlResult, ServerSignalProcessInput, ServerSignalProcessResult, ServerUpsertKeybindingInput, @@ -223,6 +225,7 @@ export const WS_METHODS = { serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverListClaudeResumableSessions: "server.listClaudeResumableSessions", serverGetClaudeResumableSessionTranscript: "server.getClaudeResumableSessionTranscript", + serverSetClaudeThreadRemoteControl: "server.setClaudeThreadRemoteControl", serverSignalProcess: "server.signalProcess", // Cloud environment methods @@ -352,6 +355,15 @@ export const WsServerGetClaudeResumableSessionTranscriptRpc = Rpc.make( }, ); +export const WsServerSetClaudeThreadRemoteControlRpc = Rpc.make( + WS_METHODS.serverSetClaudeThreadRemoteControl, + { + payload: ServerSetClaudeThreadRemoteControlInput, + success: ServerSetClaudeThreadRemoteControlResult, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -738,6 +750,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessResourceHistoryRpc, WsServerListClaudeResumableSessionsRpc, WsServerGetClaudeResumableSessionTranscriptRpc, + WsServerSetClaudeThreadRemoteControlRpc, WsServerSignalProcessRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 48cf7eff8a507..b6e71d5d5e8a6 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -455,6 +455,29 @@ export const ServerGetClaudeResumableSessionTranscriptResult = Schema.Struct({ export type ServerGetClaudeResumableSessionTranscriptResult = typeof ServerGetClaudeResumableSessionTranscriptResult.Type; +/** + * Turns Remote Control on/off for an already-created Claude Code thread + * (as opposed to `ThreadTurnStartBootstrapCreateThread.remoteControl`, + * which only applies at thread-creation time). Since Remote Control is a + * `claude` process launch flag, not something a running process can be + * told to flip live, applying this to a thread with an active session + * stops that session — it relaunches with the flag on the thread's next + * turn. + */ +export const ServerSetClaudeThreadRemoteControlInput = Schema.Struct({ + threadId: ThreadId, + providerInstanceId: ProviderInstanceId, + enabled: Schema.Boolean, +}); +export type ServerSetClaudeThreadRemoteControlInput = + typeof ServerSetClaudeThreadRemoteControlInput.Type; + +export const ServerSetClaudeThreadRemoteControlResult = Schema.Struct({ + applied: Schema.Boolean, +}); +export type ServerSetClaudeThreadRemoteControlResult = + typeof ServerSetClaudeThreadRemoteControlResult.Type; + export const ServerSignalProcessInput = Schema.Struct({ pid: PositiveInt, signal: ServerProcessSignal, From 2d462756373be062fb6cb5ca970bac1717593ff7 Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:29:20 +0200 Subject: [PATCH 8/9] feat: refresh a resumed thread's imported history when reopened If you resume a Claude session into a T3 thread and then keep chatting with that same session directly in a terminal (claude --resume ), T3 previously had no way to see those messages short of re-resuming from scratch. Adds serverGetClaudeThreadImportedHistory, which resolves a thread's bound session id server-side from its ProviderSessionDirectory binding and re-reads the on-disk transcript. The client calls it whenever a resumed thread is opened/switched to, merging in only messages newer than everything T3 already knows about (its own driven turns plus the original import) so nothing gets duplicated. --- .../provider/Layers/ClaudeSessionHistory.ts | 35 ++++++++++++ apps/server/src/server.test.ts | 1 + apps/server/src/ws.ts | 9 ++++ apps/web/src/components/ChatView.tsx | 53 +++++++++++++++++++ apps/web/src/localApi.ts | 1 + packages/client-runtime/src/state/server.ts | 4 ++ packages/contracts/src/ipc.ts | 5 ++ packages/contracts/src/rpc.ts | 13 +++++ packages/contracts/src/server.ts | 25 +++++++++ 9 files changed, 146 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts index c697471d29d67..4b922fd7f6fa8 100644 --- a/apps/server/src/provider/Layers/ClaudeSessionHistory.ts +++ b/apps/server/src/provider/Layers/ClaudeSessionHistory.ts @@ -35,6 +35,8 @@ import { type ServerClaudeResumableSessionMessage, type ServerGetClaudeResumableSessionTranscriptInput, type ServerGetClaudeResumableSessionTranscriptResult, + type ServerGetClaudeThreadImportedHistoryInput, + type ServerGetClaudeThreadImportedHistoryResult, type ServerListClaudeResumableSessionsInput, type ServerListClaudeResumableSessionsResult, type ServerSetClaudeThreadRemoteControlInput, @@ -262,6 +264,18 @@ export class ClaudeSessionHistory extends Context.Service< readonly getTranscript: ( input: ServerGetClaudeResumableSessionTranscriptInput, ) => Effect.Effect; + /** + * Re-reads a resumed thread's bound on-disk transcript, resolving the + * bound session id from the thread's `ProviderSessionDirectory` + * binding server-side (the caller only needs to know the thread, not + * which session it's bound to). Returns `{messages: []}` — not an + * error — when the thread has no bound `resume` session (never + * resumed) or the binding can't be read, matching `getTranscript`'s + * best-effort behavior. + */ + readonly getImportedHistoryForThread: ( + input: ServerGetClaudeThreadImportedHistoryInput, + ) => Effect.Effect; /** * Binds thread-creation-time Claude launch choices (resume a picked * on-disk session, and/or start with Remote Control enabled) onto a @@ -420,6 +434,26 @@ export const make = Effect.gen(function* () { return { messages: trimmedMessages }; }); + const getImportedHistoryForThread: ClaudeSessionHistory["Service"]["getImportedHistoryForThread"] = + Effect.fn("ClaudeSessionHistory.getImportedHistoryForThread")(function* (input) { + const binding = yield* providerSessionDirectory + .getBinding(input.threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + const resumeCursor = Option.getOrUndefined(binding)?.resumeCursor; + const resumeSessionId = + resumeCursor !== null && typeof resumeCursor === "object" && resumeCursor !== undefined + ? (resumeCursor as Record).resume + : undefined; + if (typeof resumeSessionId !== "string") { + return { messages: [] }; + } + return yield* getTranscript({ + workspaceRoot: input.workspaceRoot, + providerInstanceId: input.providerInstanceId, + sessionId: resumeSessionId, + }); + }); + const bindSessionLaunchOptions: ClaudeSessionHistory["Service"]["bindSessionLaunchOptions"] = Effect.fn("ClaudeSessionHistory.bindSessionLaunchOptions")(function* (input) { if (input.resumeExternalSessionId === undefined && input.remoteControl === undefined) { @@ -493,6 +527,7 @@ export const make = Effect.gen(function* () { return ClaudeSessionHistory.of({ list, getTranscript, + getImportedHistoryForThread, bindSessionLaunchOptions, setThreadRemoteControl, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 230d3138c0249..a267ceae61db5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -613,6 +613,7 @@ const buildAppUnderTest = (options?: { Layer.mock(ClaudeSessionHistory.ClaudeSessionHistory)({ list: () => Effect.succeed({ sessions: [] }), getTranscript: () => Effect.succeed({ messages: [] }), + getImportedHistoryForThread: () => Effect.succeed({ messages: [] }), bindSessionLaunchOptions: () => Effect.void, setThreadRemoteControl: () => Effect.succeed({ applied: true }), }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3ac1cb5821891..552b32d328b40 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -309,6 +309,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverListClaudeResumableSessions, AuthOrchestrationReadScope], [WS_METHODS.serverGetClaudeResumableSessionTranscript, AuthOrchestrationReadScope], + [WS_METHODS.serverGetClaudeThreadImportedHistory, AuthOrchestrationReadScope], [WS_METHODS.serverSetClaudeThreadRemoteControl, AuthOrchestrationOperateScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], @@ -1584,6 +1585,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetClaudeThreadImportedHistory]: (input) => + observeRpcEffect( + WS_METHODS.serverGetClaudeThreadImportedHistory, + claudeSessionHistory.getImportedHistoryForThread(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverSetClaudeThreadRemoteControl]: (input) => observeRpcEffect( WS_METHODS.serverSetClaudeThreadRemoteControl, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 703c5eea3efd7..6afa1c6ceaebd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1145,6 +1145,10 @@ function ChatViewContent(props: ChatViewProps) { serverEnvironment.setClaudeThreadRemoteControl, { reportFailure: false }, ); + const getClaudeThreadImportedHistory = useAtomCommand( + serverEnvironment.getClaudeThreadImportedHistory, + { reportFailure: false }, + ); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -2279,6 +2283,8 @@ function ChatViewContent(props: ChatViewProps) { } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); + const timelineMessagesRef = useRef(timelineMessages); + timelineMessagesRef.current = timelineMessages; const importedResumeHistoryMessages = useResumeSessionHistoryStore((store) => activeThread ? (store.historyByThreadId[activeThread.id] ?? EMPTY_RESUME_HISTORY) @@ -2301,6 +2307,53 @@ function ChatViewContent(props: ChatViewProps) { })); return [...importedChatMessages, ...timelineMessages]; }, [importedResumeHistoryMessages, timelineMessages]); + useEffect(() => { + if (!activeThread || isLocalDraftThread || !activeProject) return; + if (selectedProvider !== ProviderDriverKind.make("claudeAgent")) return; + const threadId = activeThread.id; + const instanceId = activeThread.modelSelection.instanceId; + const workspaceRoot = activeProject.workspaceRoot; + let cancelled = false; + void (async () => { + const result = await getClaudeThreadImportedHistory({ + environmentId, + input: { threadId, workspaceRoot, providerInstanceId: instanceId }, + }); + if (cancelled || result._tag === "Failure") return; + const fetchedMessages = result.value.messages; + if (fetchedMessages.length === 0) return; + const { historyByThreadId, setResumeSessionHistory } = + useResumeSessionHistoryStore.getState(); + const existingImported = historyByThreadId[threadId] ?? EMPTY_RESUME_HISTORY; + // Re-fetching returns the on-disk transcript's FULL current state, so + // it also includes every turn T3 itself has already driven for this + // thread (they're all written to the same file by the same `claude` + // process) — only messages newer than everything T3 already knows + // about (either from the original import, or its own live timeline) + // are actually new content from a session continued directly in the + // CLI. Anything at/before that point would otherwise show up twice. + const latestKnownCreatedAt = [...existingImported, ...timelineMessagesRef.current].reduce( + (max, message) => (message.createdAt > max ? message.createdAt : max), + "", + ); + const newMessages = fetchedMessages.filter( + (message) => message.createdAt > latestKnownCreatedAt, + ); + if (newMessages.length === 0) return; + const merged = [...existingImported, ...newMessages].sort((a, b) => + a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0, + ); + setResumeSessionHistory(threadId, merged); + })(); + return () => { + cancelled = true; + }; + // Deliberately keyed on activeThread.id (not on timelineMessages, which + // changes constantly while streaming) — this is a refresh-on-open, not + // a live poll. Current timeline/imported state is read fresh via + // `timelineMessagesRef`/`getState()` when the fetch resolves. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeThread?.id, isLocalDraftThread, activeProject, selectedProvider, environmentId]); const timelineEntries = useMemo( () => deriveTimelineEntries( diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2b35081be19a2..cca26e1bb12f1 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -77,6 +77,7 @@ function createBrowserLocalApi(): LocalApi { getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), listClaudeResumableSessions: () => Promise.reject(unavailableLocalBackendError()), getClaudeResumableSessionTranscript: () => Promise.reject(unavailableLocalBackendError()), + getClaudeThreadImportedHistory: () => Promise.reject(unavailableLocalBackendError()), setClaudeThreadRemoteControl: () => Promise.reject(unavailableLocalBackendError()), signalProcess: () => Promise.reject(unavailableLocalBackendError()), }, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 1269b04d40b19..54a78f424eebf 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -306,6 +306,10 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:get-claude-resumable-session-transcript", tag: WS_METHODS.serverGetClaudeResumableSessionTranscript, }), + getClaudeThreadImportedHistory: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:get-claude-thread-imported-history", + tag: WS_METHODS.serverGetClaudeThreadImportedHistory, + }), setClaudeThreadRemoteControl: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:set-claude-thread-remote-control", tag: WS_METHODS.serverSetClaudeThreadRemoteControl, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8475bba05137b..c1bb548bb3e7c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -41,6 +41,8 @@ import type { ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, + ServerGetClaudeThreadImportedHistoryInput, + ServerGetClaudeThreadImportedHistoryResult, ServerProviderUpdateInput, ServerProviderUpdatedPayload, ServerRemoveKeybindingResult, @@ -1166,6 +1168,9 @@ export interface LocalApi { getClaudeResumableSessionTranscript: ( input: ServerGetClaudeResumableSessionTranscriptInput, ) => Promise; + getClaudeThreadImportedHistory: ( + input: ServerGetClaudeThreadImportedHistoryInput, + ) => Promise; setClaudeThreadRemoteControl: ( input: ServerSetClaudeThreadRemoteControlInput, ) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index aa7321da34cbb..a3bcf0a5a3b4c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -133,6 +133,8 @@ import { ServerListClaudeResumableSessionsResult, ServerGetClaudeResumableSessionTranscriptInput, ServerGetClaudeResumableSessionTranscriptResult, + ServerGetClaudeThreadImportedHistoryInput, + ServerGetClaudeThreadImportedHistoryResult, ServerSetClaudeThreadRemoteControlInput, ServerSetClaudeThreadRemoteControlResult, ServerSignalProcessInput, @@ -225,6 +227,7 @@ export const WS_METHODS = { serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverListClaudeResumableSessions: "server.listClaudeResumableSessions", serverGetClaudeResumableSessionTranscript: "server.getClaudeResumableSessionTranscript", + serverGetClaudeThreadImportedHistory: "server.getClaudeThreadImportedHistory", serverSetClaudeThreadRemoteControl: "server.setClaudeThreadRemoteControl", serverSignalProcess: "server.signalProcess", @@ -355,6 +358,15 @@ export const WsServerGetClaudeResumableSessionTranscriptRpc = Rpc.make( }, ); +export const WsServerGetClaudeThreadImportedHistoryRpc = Rpc.make( + WS_METHODS.serverGetClaudeThreadImportedHistory, + { + payload: ServerGetClaudeThreadImportedHistoryInput, + success: ServerGetClaudeThreadImportedHistoryResult, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerSetClaudeThreadRemoteControlRpc = Rpc.make( WS_METHODS.serverSetClaudeThreadRemoteControl, { @@ -750,6 +762,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessResourceHistoryRpc, WsServerListClaudeResumableSessionsRpc, WsServerGetClaudeResumableSessionTranscriptRpc, + WsServerGetClaudeThreadImportedHistoryRpc, WsServerSetClaudeThreadRemoteControlRpc, WsServerSignalProcessRpc, WsCloudGetRelayClientStatusRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b6e71d5d5e8a6..6ee9870eef69f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -455,6 +455,31 @@ export const ServerGetClaudeResumableSessionTranscriptResult = Schema.Struct({ export type ServerGetClaudeResumableSessionTranscriptResult = typeof ServerGetClaudeResumableSessionTranscriptResult.Type; +/** + * Re-reads a resumed thread's bound on-disk Claude session transcript, for + * threads created via the "resume a previous session" flow. The thread may + * have gained messages since it was first imported — e.g. the user + * continued the same underlying Claude session directly in a terminal + * (`claude --resume `) — so this is called again each time the thread + * is opened. Resolves the thread's bound session id server-side (from its + * `ProviderSessionDirectory` binding); returns `{messages: []}` if the + * thread was never resumed (no bound session) rather than an error, since + * that's the common case for most threads. + */ +export const ServerGetClaudeThreadImportedHistoryInput = Schema.Struct({ + threadId: ThreadId, + workspaceRoot: TrimmedNonEmptyString, + providerInstanceId: Schema.optional(ProviderInstanceId), +}); +export type ServerGetClaudeThreadImportedHistoryInput = + typeof ServerGetClaudeThreadImportedHistoryInput.Type; + +export const ServerGetClaudeThreadImportedHistoryResult = Schema.Struct({ + messages: Schema.Array(ServerClaudeResumableSessionMessage), +}); +export type ServerGetClaudeThreadImportedHistoryResult = + typeof ServerGetClaudeThreadImportedHistoryResult.Type; + /** * Turns Remote Control on/off for an already-created Claude Code thread * (as opposed to `ThreadTurnStartBootstrapCreateThread.remoteControl`, From 2ed6a86b219dfd7248548eeda97b79bd9b26b569 Mon Sep 17 00:00:00 2001 From: 88871 <122596760+88871@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:43:32 +0200 Subject: [PATCH 9/9] fix: actually enable Claude Remote Control (was a silent no-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --remote-control as a bare CLI launch flag is a no-op under the Agent SDK's query() — it only does something in the interactive CLI's own terminal-driven code path. Remote Control has to be turned on via a control request on the running session instead: query()'s returned object has an undocumented enableRemoteControl(enabled) method (not in the SDK's public types, but real at runtime) that sends the same {subtype: "remote_control"} request the interactive CLI's /remote-control slash command triggers internally. Calls it once the session's query object exists, whenever remote control should be on (persistent per-instance setting or a per-thread choice). Verified live: the previously silent flag now yields a genuine "bridge_state: ready" system message from the CLI subprocess, confirming the claude.ai bridge is actually connecting -- this was completely inert before. Also captures a connect URL from either the enableRemoteControl response or the bridge_state message if present, surfaced as a runtime warning in the thread's work log (best-effort; neither has reliably included one in testing so far). --- .../src/provider/Layers/ClaudeAdapter.ts | 94 +++++++++++++++++-- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 26dc70487eb19..31a64df592bc3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -216,6 +216,17 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; readonly getContextUsage?: () => Promise; + /** + * Turns Remote Control on/off for the running session via a + * `{subtype: "remote_control", enabled, name?}` control request — the + * same mechanism the interactive CLI's `/remote-control` slash command + * uses internally. Not declared in the SDK's public `Query` type (or the + * `SDKMessage`/`SDKSystemMessage` unions its `bridge_status` response + * arrives as), but is a real method on the object `query()` returns. + * Optional here only because hand-rolled test doubles for + * `ClaudeQueryRuntime` don't implement it. + */ + readonly enableRemoteControl?: (enabled: boolean, name?: string) => Promise; readonly close: () => void; } @@ -2605,6 +2616,35 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } + // Also undeclared (see above) — status pushed after the `remote_control` + // control request `enableRemoteControl` sends (see `ClaudeQueryRuntime`). + // Field shape isn't documented anywhere (undeclared in the SDK's own + // types); read whatever of `state`/`url`/`content`/`sessionUrl` is + // present defensively rather than assuming one exact shape. Surfaced as + // a runtime warning (visible in the thread's work log) since there's no + // dedicated UI for it yet. + if ((message.subtype as string) === "bridge_state") { + const bridgeMessage = message as unknown as { + state?: unknown; + url?: unknown; + sessionUrl?: unknown; + content?: unknown; + }; + const url = + typeof bridgeMessage.url === "string" + ? bridgeMessage.url + : typeof bridgeMessage.sessionUrl === "string" + ? bridgeMessage.sessionUrl + : undefined; + const state = typeof bridgeMessage.state === "string" ? bridgeMessage.state : undefined; + const content = + typeof bridgeMessage.content === "string" + ? bridgeMessage.content + : `Remote Control ${state ?? "status"}${url ? ` · ${url}` : ""}`; + yield* emitRuntimeWarning(context, content, url ? { url } : undefined); + return; + } + switch (message.subtype) { case "init": yield* offerRuntimeEvent({ @@ -3495,13 +3535,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = { ...parseCliArgs(claudeSettings.launchArgs).flags, - // Either the per-instance persistent setting or a per-thread - // choice made when the thread was created (see - // ClaudeSessionHistory.bindSessionLaunchOptions) enables it. - ...(claudeSettings.remoteControl || resumeState?.remoteControl - ? { "remote-control": null } - : {}), }; + // `--remote-control` as a bare CLI launch flag is a no-op under + // `query()` (it only does something in the interactive CLI's own + // terminal-driven code path) — Remote Control has to be turned on via + // a control request on the running session instead, once the query + // object exists (see the `enableRemoteControl` call below). Either + // the per-instance persistent setting or a per-thread choice made + // when the thread was created (see + // ClaudeSessionHistory.bindSessionLaunchOptions) enables it. + const shouldEnableRemoteControl = claudeSettings.remoteControl || resumeState?.remoteControl; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); @@ -3660,6 +3703,45 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Ref.set(contextRef, context); sessions.set(threadId, context); + if (shouldEnableRemoteControl) { + const enableRemoteControlResult = yield* Effect.promise(() => + (context.query.enableRemoteControl?.(true) ?? Promise.resolve(undefined)).then( + (response): { readonly ok: true; readonly response: unknown } => ({ + ok: true, + response, + }), + (cause: unknown): { readonly ok: false; readonly cause: unknown } => ({ + ok: false, + cause, + }), + ), + ); + if (!enableRemoteControlResult.ok) { + yield* Effect.logWarning("claude.remoteControl.enable-failed", { + threadId, + cause: enableRemoteControlResult.cause, + }); + } else { + // Undocumented response shape (see `ClaudeQueryRuntime.enableRemoteControl`) + // — read a connect URL out of it defensively if one is present, + // rather than only relying on the later async `bridge_state` + // message (see `handleSystemMessage`), which may not carry one. + const response = enableRemoteControlResult.response as + | { url?: unknown; sessionUrl?: unknown } + | null + | undefined; + const url = + typeof response?.url === "string" + ? response.url + : typeof response?.sessionUrl === "string" + ? response.sessionUrl + : undefined; + if (url) { + yield* emitRuntimeWarning(context, `Remote Control ready · ${url}`, { url }); + } + } + } + const sessionStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.started",