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 (