Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ function processTone(value: string): AgentStatusField['tone'] {

function transcriptTone(value: string): AgentStatusField['tone'] {
if (value === 'error') return 'bad'
if (value === 'loading') return 'warn'
if (value === 'loading' || value === 'disconnected') return 'warn'
if (value === 'ready' || value === 'idle') return 'good'
return 'neutral'
}
10 changes: 7 additions & 3 deletions src/renderer/src/workspace/hook/actions/initialHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import * as perf from '@renderer/performance/client'
import { hasDurableProviderSession } from '@renderer/workspace/providerSessionIdentity'
import {
codexEventType,
codexTurnIdFromEventPayload,
Expand Down Expand Up @@ -94,15 +95,18 @@ export async function loadInitialHistoryForSession({
const kind = meta?.kind ?? 'claude'
if (!meta || (kind !== 'claude' && kind !== 'codex')) return

if (!meta.providerSessionId) {
if (!hasDurableProviderSession(meta)) {
setRuntimes(prev => {
const current = prev[sessionId] ?? emptyRuntime()
const isProvisional = meta.providerSessionIdSource === 'proxy-header'
return {
...prev,
[sessionId]: {
...current,
transcriptStatus: 'ready',
transcriptError: null,
transcriptStatus: isProvisional ? 'disconnected' : 'ready',
transcriptError: isProvisional
? 'Provider session was observed in proxy traffic, but no committed transcript is known yet.'
: null,
},
}
})
Expand Down
15 changes: 9 additions & 6 deletions src/renderer/src/workspace/hook/actions/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selecto
import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionActions } from '@renderer/workspace/hook/actions/session'
import { resumableProviderSessionId } from '@renderer/workspace/providerSessionIdentity'

// Provider-level actions on the focused pane.
//
Expand Down Expand Up @@ -49,7 +50,8 @@ export function useProviderActions(
const targetKind = sourceKind === 'claude' ? 'codex' : 'claude'

try {
if (!meta.providerSessionId) {
const sourceProviderSessionId = resumableProviderSessionId(meta)
if (!sourceProviderSessionId) {
const draftImages =
refs.latestRuntimesRef.current[sourceSessionId]?.draftImages ?? []

Expand Down Expand Up @@ -110,7 +112,7 @@ export function useProviderActions(
// dead pane.
const result = await window.api.switchProvider({
sourceKind,
sourceProviderSessionId: meta.providerSessionId,
sourceProviderSessionId,
cwd: meta.cwd,
})

Expand Down Expand Up @@ -146,15 +148,16 @@ export function useProviderActions(
showPaneToast(sourceSessionId, 'Only Claude and Codex panes can reload')
return
}
if (!meta.providerSessionId) {
const resumeSessionId = resumableProviderSessionId(meta)
if (!resumeSessionId) {
showPaneToast(sourceSessionId, 'Provider session id is not ready yet')
return
}

try {
const newSessionId = await sessionActions.replaceSession(meta.cwd, {
kind,
resumeSessionId: meta.providerSessionId,
resumeSessionId,
builtInMcpDomains: meta.builtInMcpDomains,
})
if (!newSessionId) return
Expand Down Expand Up @@ -209,11 +212,11 @@ export function useProviderActions(
showPaneToast(sourceSessionId, 'Only Claude and Codex panes support rewind')
return
}
if (!meta.providerSessionId) {
const previousProviderSessionId = resumableProviderSessionId(meta)
if (!previousProviderSessionId) {
showPaneToast(sourceSessionId, 'Provider session id is not ready yet')
return
}
const previousProviderSessionId = meta.providerSessionId
if (kind !== anchor.kind) {
showPaneToast(
sourceSessionId,
Expand Down
75 changes: 64 additions & 11 deletions src/renderer/src/workspace/hook/actions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import type {
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import { loadInitialHistoryForSession } from '@renderer/workspace/hook/actions/initialHistory'
import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
import {
hasDurableProviderSession,
resumableProviderSessionId,
withoutProvisionalProviderSession,
} from '@renderer/workspace/providerSessionIdentity'
import {
collectLiveProcessIds,
collectOwnedSessionIds,
Expand Down Expand Up @@ -78,6 +83,27 @@ export type SessionActions = {
}

function softReloadRuntime(current: SessionRuntime, hasProviderSession: boolean): SessionRuntime {
if (!hasProviderSession) {
// WHY no-provider soft reload is non-destructive:
//
// Soft reload used to mean "discard renderer-derived state, then replay the
// committed transcript from disk." That only works when a providerSessionId
// points at a transcript we can load. In the #290 failure mode Claude is
// still alive through proxy/semantic/screen, but the committed JSONL is
// missing and the pane may have no provider id yet. Resetting entries,
// ghosts, and semantic state in that condition erases the only visible
// progress and then labels the empty pane "ready." Preserve the live-only
// state and make the durability problem explicit instead.
return {
...current,
scrollToLatestRequest: current.scrollToLatestRequest + 1,
hasOlderHistory: false,
transcriptStatus: 'disconnected',
transcriptError:
'Cannot soft reload this agent view because no committed provider transcript is known yet.',
}
}

const reset = emptyRuntime()

// WHY this is a selective reset instead of a fresh `emptyRuntime()`:
Expand Down Expand Up @@ -121,8 +147,8 @@ function softReloadRuntime(current: SessionRuntime, hasProviderSession: boolean)
turnStartedAt: current.turnStartedAt,
phaseChangedAt: current.phaseChangedAt,
submittedAt: current.submittedAt,
hasOlderHistory: hasProviderSession,
transcriptStatus: hasProviderSession ? 'loading' : 'ready',
hasOlderHistory: true,
transcriptStatus: 'loading',
transcriptError: null,
}
}
Expand Down Expand Up @@ -189,7 +215,10 @@ export function useSessionActions(
kind,
...(tmuxName ? { tmuxName } : {}),
...(kind !== 'terminal' && opts?.resumeSessionId
? { providerSessionId: opts.resumeSessionId }
? {
providerSessionId: opts.resumeSessionId,
providerSessionIdSource: 'resume-request' as const,
}
: {}),
...(kind !== 'terminal' && builtInMcpDomains
? { builtInMcpDomains }
Expand All @@ -209,7 +238,7 @@ export function useSessionActions(
...prev,
[sessionId]: {
...emptyRuntime(),
hasOlderHistory: kind !== 'terminal' && Boolean(meta.providerSessionId),
hasOlderHistory: kind !== 'terminal' && hasDurableProviderSession(meta),
transcriptStatus:
kind !== 'terminal' && meta.providerSessionId ? 'loading' : 'ready',
transcriptError: null,
Expand Down Expand Up @@ -445,7 +474,12 @@ export function useSessionActions(
...(sessions[newId] ?? { cwd, kind: nextKind }),
cwd,
kind: nextKind,
...(spawnOpts.resumeSessionId ? { providerSessionId: spawnOpts.resumeSessionId } : {}),
...(spawnOpts.resumeSessionId
? {
providerSessionId: spawnOpts.resumeSessionId,
providerSessionIdSource: 'resume-request' as const,
}
: {}),
...(builtInMcpDomains ? { builtInMcpDomains } : {}),
}
const detachedSessions = { ...prev.detachedSessions }
Expand Down Expand Up @@ -567,17 +601,19 @@ export function useSessionActions(
kind !== 'terminal'
? normalizeSessionBuiltInMcpDomains(meta.builtInMcpDomains)
: undefined
const resumeSessionId = resumableProviderSessionId(meta)
const restoredMeta = withoutProvisionalProviderSession(meta)
const { sessionId: newId } = await window.api.spawnSession({
kind,
cwd: meta.cwd,
resumeSessionId: meta.providerSessionId,
resumeSessionId,
dangerousMode,
useProxy: kind !== 'terminal' ? refs.useProxyStreamingRef.current : undefined,
builtInMcpDomains,
})
idMap.set(oldId, newId)
freshSessions[newId] = {
...meta,
...restoredMeta,
...(builtInMcpDomains ? { builtInMcpDomains } : {}),
}
} catch {
Expand Down Expand Up @@ -605,9 +641,11 @@ export function useSessionActions(
const restored: SessionRuntime = { ...(existing ?? emptyRuntime()) }
restored.draftInput = oldRuntimes[oldId]?.draftInput ?? existing?.draftInput ?? ''
restored.hasOlderHistory =
Boolean(existing?.hasOlderHistory) || Boolean(freshSessions[newId]?.providerSessionId)
Boolean(existing?.hasOlderHistory) || hasDurableProviderSession(freshSessions[newId])
restored.transcriptStatus =
existing?.transcriptStatus === 'ready' || existing?.transcriptStatus === 'error'
existing?.transcriptStatus === 'ready' ||
existing?.transcriptStatus === 'error' ||
existing?.transcriptStatus === 'disconnected'
? existing.transcriptStatus
: freshSessions[newId]?.providerSessionId ? 'loading' : 'ready'
restored.transcriptError = existing?.transcriptError ?? null
Expand Down Expand Up @@ -710,7 +748,7 @@ export function useSessionActions(
}
})
for (const [newId, meta] of Object.entries(freshSessions)) {
if (!meta.providerSessionId) continue
if (!hasDurableProviderSession(meta)) continue
void loadInitialHistoryForSession({
sessionId: newId,
meta,
Expand Down Expand Up @@ -746,7 +784,22 @@ export function useSessionActions(
const kind = meta.kind ?? 'claude'
if (kind !== 'claude' && kind !== 'codex') return null

const hasProviderSession = Boolean(meta.providerSessionId)
const hasProviderSession = hasDurableProviderSession(meta)
if (!hasProviderSession) {
const timer = refs.bootstrapTimersRef.current.get(sessionId)
if (timer) {
clearTimeout(timer)
refs.bootstrapTimersRef.current.delete(sessionId)
}
setRuntimes(prev => {
const current = prev[sessionId] ?? emptyRuntime()
return {
...prev,
[sessionId]: softReloadRuntime(current, false),
}
})
return sessionId
}

const timer = refs.bootstrapTimersRef.current.get(sessionId)
if (timer) {
Expand Down
9 changes: 6 additions & 3 deletions src/renderer/src/workspace/hook/actions/undoClose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { ClosedPane, ClosedTab } from '@renderer/lib/undoClose'
import type { WorkspaceSetState } from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionActions } from '@renderer/workspace/hook/actions/session'
import { resumableProviderSessionId } from '@renderer/workspace/providerSessionIdentity'

type RestoreResult = 'restored' | 'stale' | 'retryable-failure'

Expand Down Expand Up @@ -80,7 +81,7 @@ export function useUndoCloseAction(
try {
newSessionId = await sessionActions.spawn(meta.cwd, {
kind: meta.kind ?? 'claude',
resumeSessionId: meta.providerSessionId,
resumeSessionId: resumableProviderSessionId(meta),
recoverTmuxName: meta.kind === 'terminal' ? meta.tmuxName : undefined,
})
} catch {
Expand Down Expand Up @@ -152,7 +153,7 @@ export function useUndoCloseAction(
// agents.
const newId = await sessionActions.spawn(meta.cwd, {
kind,
resumeSessionId: kind !== 'terminal' ? meta.providerSessionId : undefined,
resumeSessionId: kind !== 'terminal' ? resumableProviderSessionId(meta) : undefined,
recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined,
})
idMap.set(oldId, newId)
Expand Down Expand Up @@ -211,7 +212,9 @@ export function useUndoCloseAction(
// detached terminals doesn't silently drop tmuxName.
const newId = await sessionActions.spawn(detached.meta.cwd, {
kind,
resumeSessionId: kind !== 'terminal' ? detached.meta.providerSessionId : undefined,
resumeSessionId: kind !== 'terminal'
? resumableProviderSessionId(detached.meta)
: undefined,
recoverTmuxName: kind === 'terminal' ? detached.meta.tmuxName : undefined,
})
restoredDetached[newId] = {
Expand Down
Loading