diff --git a/packages/happy-app/sources/app/(app)/session/[id]/info.tsx b/packages/happy-app/sources/app/(app)/session/[id]/info.tsx index 59c12cf25..23c0a5eba 100644 --- a/packages/happy-app/sources/app/(app)/session/[id]/info.tsx +++ b/packages/happy-app/sources/app/(app)/session/[id]/info.tsx @@ -130,7 +130,10 @@ function SessionInfoContent({ session }: { session: Session }) { const sessionName = getSessionName(session); const sessionStatus = useSessionStatus(session); const { + canResumeInPlace, canShowResume, + resumeInPlace, + resumingInPlace, resumeSession, resumeSessionSubtitle, } = useSessionQuickActions(session); @@ -355,6 +358,15 @@ function SessionInfoContent({ session }: { session: Session }) { onPress={resumeSession} /> )} + {canResumeInPlace && ( + } + onPress={resumeInPlace} + loading={resumingInPlace} + /> + )} {sessionStatus.isConnected && ( ) } -// Table rendering uses column-first layout to ensure consistent column widths. -// Each column is rendered as a vertical container with all its cells (header + data). -// This ensures that cells in the same column have the same width, determined by the widest content. +// Table rendering uses row-first layout to ensure consistent row heights. +// Each row is rendered as a horizontal container with all its cells. +// This ensures that cells in the same row have the same height. function RenderTableBlock(props: { headers: MarkdownSpan[][], rows: MarkdownSpan[][][], @@ -310,26 +310,30 @@ function RenderTableBlock(props: { style={style.tableScrollView} > - {/* Render each column as a vertical container */} - {props.headers.map((header, colIndex) => ( - - {/* Header cell for this column */} - + {/* Header row */} + + {props.headers.map((header, colIndex) => ( + - {/* Data cells for this column */} - {props.rows.map((row, rowIndex) => ( + ))} + + {/* Data rows */} + {props.rows.map((row, rowIndex) => ( + + {props.headers.map((_, colIndex) => ( @@ -591,28 +595,25 @@ const style = StyleSheet.create((theme) => ({ flexGrow: 0, }, tableContent: { - flexDirection: 'row', - }, - tableColumn: { flexDirection: 'column', - borderRightWidth: 1, - borderRightColor: theme.colors.divider, }, - tableColumnLast: { - borderRightWidth: 0, + tableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + tableRowLast: { + borderBottomWidth: 0, }, tableCell: { + flex: 1, paddingHorizontal: 12, paddingVertical: 8, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, alignItems: 'flex-start', }, - tableCellFirst: { - borderTopWidth: 0, - }, - tableCellLast: { - borderBottomWidth: 0, + tableCellBorderRight: { + borderRightWidth: 1, + borderRightColor: theme.colors.divider, }, tableHeaderCell: { backgroundColor: theme.colors.surfaceHigh, diff --git a/packages/happy-app/sources/hooks/useSessionQuickActions.ts b/packages/happy-app/sources/hooks/useSessionQuickActions.ts index 13b49554e..a6c8e3837 100644 --- a/packages/happy-app/sources/hooks/useSessionQuickActions.ts +++ b/packages/happy-app/sources/hooks/useSessionQuickActions.ts @@ -2,7 +2,7 @@ import * as React from 'react'; import { useHappyAction } from '@/hooks/useHappyAction'; import { useNavigateToSession } from '@/hooks/useNavigateToSession'; import { Modal } from '@/modal'; -import { machineResumeSession, sessionKill } from '@/sync/ops'; +import { machineResumeSession, sessionKill, sessionResume } from '@/sync/ops'; import { maybeCleanupWorktree } from '@/hooks/useWorktreeCleanup'; import { storage, useLocalSetting, useMachine, useSetting } from '@/sync/storage'; import { Machine, Session } from '@/sync/storageTypes'; @@ -169,6 +169,18 @@ export function useSessionQuickActions( } }); + // In-place resume: fork the active session with full history (fixes corrupted context) + const [resumingInPlace, performResumeInPlace] = useHappyAction(async () => { + const result = await sessionResume(session.id); + if (!result.success) { + throw new HappyError(result.message, false); + } + }); + + const resumeInPlace = React.useCallback(() => { + performResumeInPlace(); + }, [performResumeInPlace]); + const [archivingSession, performArchive] = useHappyAction(async () => { await maybeCleanupWorktree(session.id, session.metadata?.path, session.metadata?.machineId); @@ -193,11 +205,14 @@ export function useSessionQuickActions( canArchive: sessionStatus.isConnected, canCopySessionMetadata: __DEV__ || devModeEnabled, canResume: resumeAvailability.canResume, + canResumeInPlace: sessionStatus.isConnected, canShowResume: resumeAvailability.canShowResume, copySessionMetadata, openDetails, + resumeInPlace, resumeSession, resumeSessionSubtitle: resumeAvailability.subtitle, + resumingInPlace, resumingSession, }; } diff --git a/packages/happy-app/sources/sync/ops.ts b/packages/happy-app/sources/sync/ops.ts index a406acf95..ece040930 100644 --- a/packages/happy-app/sources/sync/ops.ts +++ b/packages/happy-app/sources/sync/ops.ts @@ -502,6 +502,31 @@ export async function sessionKill(sessionId: string): Promise { + try { + const response = await apiSocket.sessionRPC( + sessionId, + 'resumeSession', + {} + ); + return response; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + /** * Permanently delete a session from the server * This will remove the session and all its associated data (messages, usage reports, access keys) diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index fbf1d88ba..02c2c45b7 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -321,6 +321,8 @@ export const en = { quickActions: 'Quick Actions', viewMachine: 'View Machine', viewMachineSubtitle: 'View machine details and sessions', + resumeInPlace: 'Restart Session', + resumeInPlaceSubtitle: 'Start fresh with full conversation history', resumeSession: 'Resume Session', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 0e4a4a9fa..a3fbfcfd7 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -324,6 +324,8 @@ export const ca: TranslationStructure = { viewMachine: 'Veure la màquina', viewMachineSubtitle: 'Veure detalls de la màquina i sessions', resumeSession: 'Resume Session', + resumeInPlace: 'Reiniciar sessió', + resumeInPlaceSubtitle: 'Reiniciar amb l\'historial complet de conversa', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index 1a78bb3f9..bf006ebb2 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -338,6 +338,8 @@ export const en: TranslationStructure = { viewMachine: 'View Machine', viewMachineSubtitle: 'View machine details and sessions', resumeSession: 'Resume Session', + resumeInPlace: 'Restart Session', + resumeInPlaceSubtitle: 'Start fresh with full conversation history', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 8a80378d3..6f88632e6 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -324,6 +324,8 @@ export const es: TranslationStructure = { viewMachine: 'Ver máquina', viewMachineSubtitle: 'Ver detalles de máquina y sesiones', resumeSession: 'Resume Session', + resumeInPlace: 'Reiniciar sesión', + resumeInPlaceSubtitle: 'Reiniciar con el historial completo de conversación', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 6cf83b86a..749179e32 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -322,6 +322,8 @@ export const it: TranslationStructure = { viewMachine: 'Visualizza macchina', viewMachineSubtitle: 'Visualizza dettagli e sessioni della macchina', resumeSession: 'Resume Session', + resumeInPlace: 'Riavvia sessione', + resumeInPlaceSubtitle: 'Ricomincia con la cronologia completa della conversazione', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 4b552f303..bce86659b 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -325,6 +325,8 @@ export const ja: TranslationStructure = { viewMachine: 'マシンを表示', viewMachineSubtitle: 'マシンの詳細とセッションを表示', resumeSession: 'Resume Session', + resumeInPlace: 'セッションを再起動', + resumeInPlaceSubtitle: '会話履歴を保持したまま再開', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index d0a8586b4..3040ed34f 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -335,6 +335,8 @@ export const pl: TranslationStructure = { viewMachine: 'Zobacz maszynę', viewMachineSubtitle: 'Zobacz szczegóły maszyny i sesje', resumeSession: 'Resume Session', + resumeInPlace: 'Uruchom sesję ponownie', + resumeInPlaceSubtitle: 'Zacznij od nowa z pełną historią rozmowy', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index f4e167523..64d32b0de 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -323,6 +323,8 @@ export const pt: TranslationStructure = { viewMachine: 'Ver máquina', viewMachineSubtitle: 'Ver detalhes da máquina e sessões', resumeSession: 'Resume Session', + resumeInPlace: 'Reiniciar sessão', + resumeInPlaceSubtitle: 'Reiniciar com o histórico completo da conversa', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index 8895244c0..ccfb4188c 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -295,6 +295,8 @@ export const ru: TranslationStructure = { viewMachine: 'Посмотреть машину', viewMachineSubtitle: 'Посмотреть детали машины и сессии', resumeSession: 'Resume Session', + resumeInPlace: 'Перезапустить сессию', + resumeInPlaceSubtitle: 'Начать заново с полной историей разговора', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index 1ef02fdef..83beb83a2 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -325,6 +325,8 @@ export const zhHans: TranslationStructure = { viewMachine: '查看设备', viewMachineSubtitle: '查看设备详情和会话', resumeSession: 'Resume Session', + resumeInPlace: '重启会话', + resumeInPlaceSubtitle: '保留完整对话历史重新开始', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index 4926595d0..e4f574ed6 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -324,6 +324,8 @@ export const zhHant: TranslationStructure = { viewMachine: '查看裝置', viewMachineSubtitle: '查看裝置詳情和工作階段', resumeSession: 'Resume Session', + resumeInPlace: '重啟會話', + resumeInPlaceSubtitle: '保留完整對話歷史重新開始', resumeSessionSubtitle: 'Resume this session on the same machine', resumeSessionSameMachineOnly: 'This session can only be resumed on the same machine it started on.', resumeSessionMachineOffline: 'This machine is offline. Resume is only available while it is online.', diff --git a/packages/happy-cli/src/claude/claudeRemote.ts b/packages/happy-cli/src/claude/claudeRemote.ts index d93215c8c..b063d1d2a 100644 --- a/packages/happy-cli/src/claude/claudeRemote.ts +++ b/packages/happy-cli/src/claude/claudeRemote.ts @@ -17,6 +17,8 @@ export async function claudeRemote(opts: { // Fixed parameters sessionId: string | null, + /** Override: resume from this session ID instead of continuing the current one */ + resumeFromSessionId?: string | null, path: string, mcpServers?: Record, claudeEnvVars?: Record, @@ -43,11 +45,12 @@ export async function claudeRemote(opts: { }) { // Check if session is valid - let startFrom = opts.sessionId; - if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { + // resumeFromSessionId takes priority (in-place resume from web UI) + let startFrom = opts.resumeFromSessionId ?? opts.sessionId; + if (startFrom && !claudeCheckSession(startFrom, opts.path)) { startFrom = null; } - + // Extract --resume from claudeArgs if present (for first spawn) if (!startFrom && opts.claudeArgs) { for (let i = 0; i < opts.claudeArgs.length; i++) { @@ -145,6 +148,8 @@ export async function claudeRemote(opts: { } }; + + // Push initial message let messages = new PushableAsyncIterable(); messages.push({ diff --git a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts index 79f6e980f..1daa7a66a 100644 --- a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts +++ b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts @@ -16,6 +16,7 @@ import { RawJSONLines } from "@/claude/types"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { getToolName } from "./utils/getToolName"; import { getAskUserQuestionToolCallIds } from "./utils/questionNotification"; +import { registerResumeSessionHandler } from "./registerResumeSessionHandler"; interface PermissionsField { date: number; @@ -95,6 +96,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // When to abort session.client.rpcHandlerManager.registerHandler('abort', doAbort); // When abort clicked session.client.rpcHandlerManager.registerHandler('switch', doSwitch); // When switch clicked + registerResumeSessionHandler(session.client.rpcHandlerManager, session, abort); // When resume clicked // Removed catch-all stdin handler - now handled by RemoteModeDisplay keyboard handlers // Create permission handler @@ -345,9 +347,11 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | abortFuture = new Future(); let modeHash: string | null = null; let mode: EnhancedMode | null = null; + const pendingResumeId = session.consumePendingResume(); try { const remoteResult = await claudeRemote({ sessionId: session.sessionId, + resumeFromSessionId: pendingResumeId, path: session.path, allowedTools: session.allowedTools ?? [], mcpServers: session.mcpServers, diff --git a/packages/happy-cli/src/claude/registerResumeSessionHandler.ts b/packages/happy-cli/src/claude/registerResumeSessionHandler.ts new file mode 100644 index 000000000..62d3c3607 --- /dev/null +++ b/packages/happy-cli/src/claude/registerResumeSessionHandler.ts @@ -0,0 +1,35 @@ +import { RpcHandlerManager } from "@/api/rpc/RpcHandlerManager"; +import { logger } from "@/lib"; +import { Session } from "./session"; + +interface ResumeSessionRequest { + // No parameters needed +} + +interface ResumeSessionResponse { + success: boolean; + message: string; +} + +export function registerResumeSessionHandler( + rpcHandlerManager: RpcHandlerManager, + session: Session, + abortCurrentSession: () => Promise +) { + rpcHandlerManager.registerHandler('resumeSession', async () => { + logger.debug('[resumeSession] Resume session request received'); + + const result = session.requestResume(); + if (!result.success) { + return result; + } + + // Abort the current Claude process so the loop restarts with --resume + await abortCurrentSession(); + + return { + success: true, + message: 'Session resume initiated' + }; + }); +} diff --git a/packages/happy-cli/src/claude/runClaude.ts b/packages/happy-cli/src/claude/runClaude.ts index b626c62b6..cdf010766 100644 --- a/packages/happy-cli/src/claude/runClaude.ts +++ b/packages/happy-cli/src/claude/runClaude.ts @@ -263,6 +263,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt let currentAllowedTools: string[] | undefined = undefined; // Track current allowed tools let currentDisallowedTools: string[] | undefined = undefined; // Track current disallowed tools + + session.onUserMessage((message) => { // Resolve permission mode from meta - pass through as-is, mapping happens at SDK boundary diff --git a/packages/happy-cli/src/claude/session.ts b/packages/happy-cli/src/claude/session.ts index 8fc67efca..62a01955e 100644 --- a/packages/happy-cli/src/claude/session.ts +++ b/packages/happy-cli/src/claude/session.ts @@ -25,6 +25,9 @@ export class Session { sessionId: string | null; mode: 'local' | 'remote' = 'local'; thinking: boolean = false; + + /** When set, the next claudeRemote launch will use --resume with this session ID */ + pendingResumeSessionId: string | null = null; /** Callbacks to be notified when session ID is found/changed */ private sessionFoundCallbacks: ((sessionId: string) => void)[] = []; @@ -144,6 +147,30 @@ export class Session { logger.debug('[Session] Session ID cleared'); } + /** + * Request a session resume. Stores the current session ID so the next + * claudeRemote launch uses --resume to fork the session with full history. + */ + requestResume = (): { success: boolean; message: string } => { + if (!this.sessionId) { + return { success: false, message: 'No active Claude session to resume from' }; + } + this.pendingResumeSessionId = this.sessionId; + this.sessionId = null; + logger.debug(`[Session] Resume requested from session: ${this.pendingResumeSessionId}`); + return { success: true, message: 'Resume requested' }; + } + + /** + * Consume the pending resume session ID (called by claudeRemoteLauncher). + * Returns the session ID to resume from, or null if no resume was requested. + */ + consumePendingResume = (): string | null => { + const id = this.pendingResumeSessionId; + this.pendingResumeSessionId = null; + return id; + } + /** * Consume one-time Claude flags from claudeArgs after Claude spawn * Handles: --resume (with or without session ID), --continue