From 87c79c3b94e9afa717899396b605b05fe62d595d Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 27 Jan 2026 14:41:43 +0800 Subject: [PATCH] feat: add session resume capability with automatic continuation Add ability to resume inactive sessions and automatically continue with new session ID. Includes message merging for preserving conversation history, UI improvements to allow sending during inactive state, and database schema migration. close #87 --- cli/src/api/apiMachine.ts | 3 +- cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/run.ts | 17 ++- server/src/index.ts | 7 +- server/src/socket/server.ts | 4 +- server/src/store/index.ts | 13 +- server/src/store/messageStore.ts | 6 +- server/src/store/messages.ts | 56 ++++++++ server/src/sync/rpcGateway.ts | 5 +- server/src/sync/sessionCache.ts | 117 ++++++++++++++++ server/src/sync/syncEngine.ts | 127 +++++++++++++++++- server/src/web/routes/cli.ts | 17 +-- server/src/web/routes/events.ts | 14 +- server/src/web/routes/guards.ts | 25 ++-- server/src/web/routes/sessions.ts | 24 ++++ web/src/api/client.ts | 8 ++ .../AssistantChat/HappyComposer.tsx | 4 +- .../components/AssistantChat/HappyThread.tsx | 4 +- web/src/components/SessionChat.tsx | 14 +- web/src/hooks/mutations/useSendMessage.ts | 56 ++++++-- web/src/hooks/useSSE.ts | 6 +- web/src/lib/assistant-runtime.ts | 4 +- web/src/lib/message-window-store.ts | 20 +++ web/src/router.tsx | 52 ++++++- 24 files changed, 534 insertions(+), 70 deletions(-) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index a8c7b1a5..cc02f9d4 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -101,7 +101,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, model, yolo, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, yolo, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -110,6 +110,7 @@ export class ApiMachineClient { const result = await spawnSession({ directory, sessionId, + resumeSessionId, machineId, approvedNewDirectoryCreation, agent, diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 31c24262..53e6adf3 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -2,6 +2,7 @@ export interface SpawnSessionOptions { machineId?: string directory: string sessionId?: string + resumeSessionId?: string approvedNewDirectoryCreation?: boolean agent?: 'claude' | 'codex' | 'gemini' model?: string diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index ab9ec28e..88f21048 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -328,11 +328,15 @@ export async function startRunner(): Promise { : agent === 'gemini' ? 'gemini' : 'claude'; - const args = [ - agentCommand, - '--hapi-starting-mode', 'remote', - '--started-by', 'runner' - ]; + const args = [agentCommand]; + if (options.resumeSessionId) { + if (agent === 'codex') { + args.push('resume', options.resumeSessionId); + } else { + args.push('--resume', options.resumeSessionId); + } + } + args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); if (options.model) { args.push('--model', options.model); } @@ -340,8 +344,7 @@ export async function startRunner(): Promise { args.push('--yolo'); } - // TODO: In future, sessionId could be used with --resume to continue existing sessions - // For now, we ignore it - each spawn creates a new session + // sessionId reserved for future use const MAX_TAIL_CHARS = 4000; let stderrTail = ''; const appendTail = (current: string, chunk: Buffer | string): string => { diff --git a/server/src/index.ts b/server/src/index.ts index 441821c7..96981565 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -170,7 +170,12 @@ async function main() { store, jwtSecret, corsOrigins, - getSession: (sessionId) => syncEngine?.getSession(sessionId) ?? store.sessions.getSession(sessionId), + getSession: (sessionId) => { + if (syncEngine) { + return syncEngine.getSession(sessionId) ?? null + } + return store.sessions.getSession(sessionId) + }, onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event), onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload), onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload), diff --git a/server/src/socket/server.ts b/server/src/socket/server.ts index 9420c2a4..bc591a11 100644 --- a/server/src/socket/server.ts +++ b/server/src/socket/server.ts @@ -139,7 +139,9 @@ export function createSocketServer(deps: SocketServerDeps): { }) terminalNs.on('connection', (socket) => registerTerminalHandlers(socket, { io, - getSession: (sessionId) => deps.getSession?.(sessionId) ?? deps.store.sessions.getSession(sessionId), + getSession: (sessionId) => { + return deps.getSession?.(sessionId) ?? deps.store.sessions.getSession(sessionId) + }, terminalRegistry, maxTerminalsPerSocket, maxTerminalsPerSession diff --git a/server/src/store/index.ts b/server/src/store/index.ts index b71c15f2..94e59675 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -22,7 +22,7 @@ export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION = 2 +const SCHEMA_VERSION: number = 3 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -88,6 +88,7 @@ export class Store { if (currentVersion === 0) { if (this.hasAnyUserTables()) { this.migrateLegacySchemaIfNeeded() + this.createSchema() this.setUserVersion(SCHEMA_VERSION) return } @@ -103,6 +104,12 @@ export class Store { return } + if (currentVersion === 2 && SCHEMA_VERSION === 3) { + this.migrateFromV2ToV3() + this.setUserVersion(SCHEMA_VERSION) + return + } + if (currentVersion !== SCHEMA_VERSION) { throw this.buildSchemaMismatchError(currentVersion) } @@ -269,6 +276,10 @@ export class Store { } } + private migrateFromV2ToV3(): void { + return + } + private getMachineColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/server/src/store/messageStore.ts b/server/src/store/messageStore.ts index 0f2565b9..c25f5732 100644 --- a/server/src/store/messageStore.ts +++ b/server/src/store/messageStore.ts @@ -1,7 +1,7 @@ import type { Database } from 'bun:sqlite' import type { StoredMessage } from './types' -import { addMessage, getMessages, getMessagesAfter } from './messages' +import { addMessage, getMessages, getMessagesAfter, mergeSessionMessages } from './messages' export class MessageStore { private readonly db: Database @@ -21,4 +21,8 @@ export class MessageStore { getMessagesAfter(sessionId: string, afterSeq: number, limit: number = 200): StoredMessage[] { return getMessagesAfter(this.db, sessionId, afterSeq, limit) } + + mergeSessionMessages(fromSessionId: string, toSessionId: string): { moved: number; oldMaxSeq: number; newMaxSeq: number } { + return mergeSessionMessages(this.db, fromSessionId, toSessionId) + } } diff --git a/server/src/store/messages.ts b/server/src/store/messages.ts index b34cd6af..bb850c0c 100644 --- a/server/src/store/messages.ts +++ b/server/src/store/messages.ts @@ -105,3 +105,59 @@ export function getMessagesAfter( return rows.map(toStoredMessage) } + +export function getMaxSeq(db: Database, sessionId: string): number { + const row = db.prepare( + 'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?' + ).get(sessionId) as { maxSeq: number } | undefined + return row?.maxSeq ?? 0 +} + +export function mergeSessionMessages( + db: Database, + fromSessionId: string, + toSessionId: string +): { moved: number; oldMaxSeq: number; newMaxSeq: number } { + if (fromSessionId === toSessionId) { + return { moved: 0, oldMaxSeq: 0, newMaxSeq: 0 } + } + + const oldMaxSeq = getMaxSeq(db, fromSessionId) + const newMaxSeq = getMaxSeq(db, toSessionId) + + try { + db.exec('BEGIN') + + if (newMaxSeq > 0 && oldMaxSeq > 0) { + db.prepare( + 'UPDATE messages SET seq = seq + ? WHERE session_id = ?' + ).run(oldMaxSeq, toSessionId) + } + + const collisions = db.prepare(` + SELECT local_id FROM messages + WHERE session_id = ? AND local_id IS NOT NULL + INTERSECT + SELECT local_id FROM messages + WHERE session_id = ? AND local_id IS NOT NULL + `).all(toSessionId, fromSessionId) as Array<{ local_id: string }> + + if (collisions.length > 0) { + const localIds = collisions.map((row) => row.local_id) + const placeholders = localIds.map(() => '?').join(', ') + db.prepare( + `UPDATE messages SET local_id = NULL WHERE session_id = ? AND local_id IN (${placeholders})` + ).run(fromSessionId, ...localIds) + } + + const result = db.prepare( + 'UPDATE messages SET session_id = ? WHERE session_id = ?' + ).run(toSessionId, fromSessionId) + + db.exec('COMMIT') + return { moved: result.changes, oldMaxSeq, newMaxSeq } + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} diff --git a/server/src/sync/rpcGateway.ts b/server/src/sync/rpcGateway.ts index d943c298..0a92e5ec 100644 --- a/server/src/sync/rpcGateway.ts +++ b/server/src/sync/rpcGateway.ts @@ -97,13 +97,14 @@ export class RpcGateway { model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', - worktreeName?: string + worktreeName?: string, + resumeSessionId?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, model, yolo, sessionType, worktreeName } + { type: 'spawn-in-directory', directory, agent, model, yolo, sessionType, worktreeName, resumeSessionId } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/server/src/sync/sessionCache.ts b/server/src/sync/sessionCache.ts index b28e51ee..b394c61d 100644 --- a/server/src/sync/sessionCache.ts +++ b/server/src/sync/sessionCache.ts @@ -36,6 +36,21 @@ export class SessionCache { return session } + resolveSessionAccess( + sessionId: string, + namespace: string + ): { ok: true; sessionId: string; session: Session } | { ok: false; reason: 'not-found' | 'access-denied' } { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (session) { + if (session.namespace !== namespace) { + return { ok: false, reason: 'access-denied' } + } + return { ok: true, sessionId, session } + } + + return { ok: false, reason: 'not-found' } + } + getActiveSessions(): Session[] { return this.getSessions().filter((session) => session.active) } @@ -267,4 +282,106 @@ export class SessionCache { this.publisher.emit({ type: 'session-removed', sessionId, namespace: session.namespace }) } + + async mergeSessions(oldSessionId: string, newSessionId: string, namespace: string): Promise { + if (oldSessionId === newSessionId) { + return + } + + const oldStored = this.store.sessions.getSessionByNamespace(oldSessionId, namespace) + const newStored = this.store.sessions.getSessionByNamespace(newSessionId, namespace) + if (!oldStored || !newStored) { + throw new Error('Session not found for merge') + } + + this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + + const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata) + if (mergedMetadata !== null && mergedMetadata !== newStored.metadata) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace) + if (!latest) break + const result = this.store.sessions.updateSessionMetadata( + newSessionId, + mergedMetadata, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + break + } + if (result.result === 'error') { + break + } + } + } + + if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { + this.store.sessions.setSessionTodos( + newSessionId, + oldStored.todos, + oldStored.todosUpdatedAt, + namespace + ) + } + + const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) + if (!deleted) { + throw new Error('Failed to delete old session during merge') + } + + const existed = this.sessions.delete(oldSessionId) + if (existed) { + this.publisher.emit({ type: 'session-removed', sessionId: oldSessionId, namespace }) + } + this.lastBroadcastAtBySessionId.delete(oldSessionId) + this.todoBackfillAttemptedSessionIds.delete(oldSessionId) + + this.refreshSession(newSessionId) + } + + private mergeSessionMetadata(oldMetadata: unknown | null, newMetadata: unknown | null): unknown | null { + if (!oldMetadata || typeof oldMetadata !== 'object') { + return newMetadata + } + if (!newMetadata || typeof newMetadata !== 'object') { + return oldMetadata + } + + const oldObj = oldMetadata as Record + const newObj = newMetadata as Record + const merged: Record = { ...newObj } + let changed = false + + if (typeof oldObj.name === 'string' && typeof newObj.name !== 'string') { + merged.name = oldObj.name + changed = true + } + + const oldSummary = oldObj.summary as { text?: unknown; updatedAt?: unknown } | undefined + const newSummary = newObj.summary as { text?: unknown; updatedAt?: unknown } | undefined + const oldUpdatedAt = typeof oldSummary?.updatedAt === 'number' ? oldSummary.updatedAt : null + const newUpdatedAt = typeof newSummary?.updatedAt === 'number' ? newSummary.updatedAt : null + if (oldUpdatedAt !== null && (newUpdatedAt === null || oldUpdatedAt > newUpdatedAt)) { + merged.summary = oldSummary + changed = true + } + + if (oldObj.worktree && !newObj.worktree) { + merged.worktree = oldObj.worktree + changed = true + } + + if (typeof oldObj.path === 'string' && typeof newObj.path !== 'string') { + merged.path = oldObj.path + changed = true + } + if (typeof oldObj.host === 'string' && typeof newObj.host !== 'string') { + merged.host = oldObj.host + changed = true + } + + return changed ? merged : newMetadata + } } diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 1ad6b0b1..8cc520e3 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -23,6 +23,10 @@ export type { Machine } from './machineCache' export type { SyncEventListener } from './eventPublisher' export type { RpcCommandResponse, RpcPathExistsResponse, RpcReadFileResponse, RpcUploadFileResponse, RpcDeleteUploadResponse } from './rpcGateway' +export type ResumeSessionResult = + | { type: 'success'; sessionId: string } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } + export class SyncEngine { private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache @@ -62,7 +66,7 @@ export class SyncEngine { return event.namespace } if ('sessionId' in event) { - return this.sessionCache.getSession(event.sessionId)?.namespace + return this.getSession(event.sessionId)?.namespace } if ('machineId' in event) { return this.machineCache.getMachine(event.machineId)?.namespace @@ -79,11 +83,23 @@ export class SyncEngine { } getSession(sessionId: string): Session | undefined { - return this.sessionCache.getSession(sessionId) + return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined } getSessionByNamespace(sessionId: string, namespace: string): Session | undefined { - return this.sessionCache.getSessionByNamespace(sessionId, namespace) + const session = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!session || session.namespace !== namespace) { + return undefined + } + return session + } + + resolveSessionAccess( + sessionId: string, + namespace: string + ): { ok: true; sessionId: string; session: Session } | { ok: false; reason: 'not-found' | 'access-denied' } { + return this.sessionCache.resolveSessionAccess(sessionId, namespace) } getActiveSessions(): Session[] { @@ -142,7 +158,7 @@ export class SyncEngine { } if (event.type === 'message-received' && event.sessionId) { - if (!this.sessionCache.getSession(event.sessionId)) { + if (!this.getSession(event.sessionId)) { this.sessionCache.refreshSession(event.sessionId) } } @@ -273,9 +289,108 @@ export class SyncEngine { model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', - worktreeName?: string + worktreeName?: string, + resumeSessionId?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { - return await this.rpcGateway.spawnSession(machineId, directory, agent, model, yolo, sessionType, worktreeName) + return await this.rpcGateway.spawnSession(machineId, directory, agent, model, yolo, sessionType, worktreeName, resumeSessionId) + } + + async resumeSession(sessionId: string, namespace: string): Promise { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', + code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' + } + } + + const session = access.session + if (session.active) { + return { type: 'success', sessionId: access.sessionId } + } + + const metadata = session.metadata + if (!metadata || typeof metadata.path !== 'string') { + return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' } + } + + const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' + ? metadata.flavor + : 'claude' + const resumeToken = flavor === 'codex' + ? metadata.codexSessionId + : flavor === 'gemini' + ? metadata.geminiSessionId + : metadata.claudeSessionId + + if (!resumeToken) { + return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' } + } + + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + if (onlineMachines.length === 0) { + return { type: 'error', message: 'No machine online', code: 'no_machine_online' } + } + + const targetMachine = (() => { + if (metadata.machineId) { + const exact = onlineMachines.find((machine) => machine.id === metadata.machineId) + if (exact) return exact + } + if (metadata.host) { + const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host) + if (hostMatch) return hostMatch + } + return null + })() + + if (!targetMachine) { + return { type: 'error', message: 'No machine online', code: 'no_machine_online' } + } + + const spawnResult = await this.rpcGateway.spawnSession( + targetMachine.id, + metadata.path, + flavor, + undefined, + undefined, + undefined, + undefined, + resumeToken + ) + + if (spawnResult.type !== 'success') { + return { type: 'error', message: spawnResult.message, code: 'resume_failed' } + } + + const becameActive = await this.waitForSessionActive(spawnResult.sessionId) + if (!becameActive) { + return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' } + } + + if (spawnResult.sessionId !== access.sessionId) { + try { + await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to merge resumed session' + return { type: 'error', message, code: 'resume_failed' } + } + } + + return { type: 'success', sessionId: spawnResult.sessionId } + } + + async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const session = this.getSession(sessionId) + if (session?.active) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return false } async checkPathsExist(machineId: string, paths: string[]): Promise> { diff --git a/server/src/web/routes/cli.ts b/server/src/web/routes/cli.ts index 6f9343d7..f85ea03e 100644 --- a/server/src/web/routes/cli.ts +++ b/server/src/web/routes/cli.ts @@ -34,15 +34,16 @@ function resolveSessionForNamespace( engine: SyncEngine, sessionId: string, namespace: string -): { ok: true; session: Session } | { ok: false; status: 403 | 404; error: string } { - const session = engine.getSessionByNamespace(sessionId, namespace) - if (session) { - return { ok: true, session } +): { ok: true; session: Session; sessionId: string } | { ok: false; status: 403 | 404; error: string } { + const access = engine.resolveSessionAccess(sessionId, namespace) + if (access.ok) { + return { ok: true, session: access.session, sessionId: access.sessionId } } - if (engine.getSession(sessionId)) { - return { ok: false, status: 403, error: 'Session access denied' } + return { + ok: false, + status: access.reason === 'access-denied' ? 403 : 404, + error: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found' } - return { ok: false, status: 404, error: 'Session not found' } } function resolveMachineForNamespace( @@ -132,7 +133,7 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono stream.writeSSE({ data: JSON.stringify(event) }), diff --git a/server/src/web/routes/guards.ts b/server/src/web/routes/guards.ts index e6c4c2d6..0e17ec07 100644 --- a/server/src/web/routes/guards.ts +++ b/server/src/web/routes/guards.ts @@ -18,19 +18,18 @@ export function requireSession( engine: SyncEngine, sessionId: string, options?: { requireActive?: boolean } -): Session | Response { +): { sessionId: string; session: Session } | Response { const namespace = c.get('namespace') - const session = engine.getSession(sessionId) - if (!session) { - return c.json({ error: 'Session not found' }, 404) + const access = engine.resolveSessionAccess(sessionId, namespace) + if (!access.ok) { + const status = access.reason === 'access-denied' ? 403 : 404 + const error = access.reason === 'access-denied' ? 'Session access denied' : 'Session not found' + return c.json({ error }, status) } - if (session.namespace !== namespace) { - return c.json({ error: 'Session access denied' }, 403) - } - if (options?.requireActive && !session.active) { + if (options?.requireActive && !access.session.active) { return c.json({ error: 'Session is inactive' }, 409) } - return session + return { sessionId: access.sessionId, session: access.session } } export function requireSessionFromParam( @@ -40,11 +39,11 @@ export function requireSessionFromParam( ): { sessionId: string; session: Session } | Response { const paramName = options?.paramName ?? 'id' const sessionId = c.req.param(paramName) - const session = requireSession(c, engine, sessionId, { requireActive: options?.requireActive }) - if (session instanceof Response) { - return session + const result = requireSession(c, engine, sessionId, { requireActive: options?.requireActive }) + if (result instanceof Response) { + return result } - return { sessionId, session } + return result } export function requireMachine( diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 4d3f0b14..5f3004b5 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -83,6 +83,30 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ session: sessionResult.session }) }) + app.post('/sessions/:id/resume', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const namespace = c.get('namespace') + const result = await engine.resumeSession(sessionResult.sessionId, namespace) + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 + : result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + app.post('/sessions/:id/upload', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9bcbb65d..0a71c02e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -253,6 +253,14 @@ export class ApiClient { }) } + async resumeSession(sessionId: string): Promise { + const response = await this.request<{ sessionId: string }>( + `/api/sessions/${encodeURIComponent(sessionId)}/resume`, + { method: 'POST' } + ) + return response.sessionId + } + async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[]): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index b355f281..19268aea 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -40,6 +40,7 @@ export function HappyComposer(props: { permissionMode?: PermissionMode modelMode?: ModelMode active?: boolean + allowSendWhenInactive?: boolean thinking?: boolean agentState?: AgentState | null contextSize?: number @@ -63,6 +64,7 @@ export function HappyComposer(props: { permissionMode: rawPermissionMode, modelMode: rawModelMode, active = true, + allowSendWhenInactive = false, thinking = false, agentState, contextSize, @@ -90,7 +92,7 @@ export function HappyComposer(props: { const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning) const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled) - const controlsDisabled = disabled || !active || threadIsDisabled + const controlsDisabled = disabled || (!active && !allowSendWhenInactive) || threadIsDisabled const trimmed = composerText.trim() const hasText = trimmed.length > 0 const hasAttachments = attachments.length > 0 diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 371ee774..09654f17 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -266,6 +266,8 @@ export function HappyThread(props: { prevLoadingMoreRef.current = props.isLoadingMoreMessages }, [props.isLoadingMoreMessages]) + const showSkeleton = props.isLoadingMessages && props.rawMessagesCount === 0 && props.pendingCount === 0 + return (