diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 3ff6aaac..67413819 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -1,14 +1,13 @@ /** * Minimal persistence functions for HAPI CLI * - * Handles settings and private key storage in ~/.hapi/ (or HAPI_HOME override) + * Handles settings, encryption key, and daemon state storage in ~/.hapi/ (or HAPI_HOME override) */ import { FileHandle } from 'node:fs/promises' import { readFile, writeFile, mkdir, open, unlink, rename, stat } from 'node:fs/promises' import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs' import { configuration } from '@/configuration' -import * as z from 'zod'; import { encodeBase64 } from '@/api/encryption'; import { isProcessAlive } from '@/utils/process'; @@ -132,65 +131,6 @@ export async function updateSettings( // Authentication // -const credentialsSchema = z.object({ - token: z.string(), - secret: z.string().base64().nullish(), // Legacy - encryption: z.object({ - publicKey: z.string().base64(), - machineKey: z.string().base64() - }).nullish() -}) - -export type Credentials = { - token: string, - encryption: { - type: 'legacy', secret: Uint8Array - } | { - type: 'dataKey', publicKey: Uint8Array, machineKey: Uint8Array - } -} - -export async function readCredentials(): Promise { - if (!existsSync(configuration.privateKeyFile)) { - return null - } - try { - const keyBase64 = (await readFile(configuration.privateKeyFile, 'utf8')); - const credentials = credentialsSchema.parse(JSON.parse(keyBase64)); - if (credentials.secret) { - return { - token: credentials.token, - encryption: { - type: 'legacy', - secret: new Uint8Array(Buffer.from(credentials.secret, 'base64')) - } - }; - } else if (credentials.encryption) { - return { - token: credentials.token, - encryption: { - type: 'dataKey', - publicKey: new Uint8Array(Buffer.from(credentials.encryption.publicKey, 'base64')), - machineKey: new Uint8Array(Buffer.from(credentials.encryption.machineKey, 'base64')) - } - } - } - } catch { - return null - } - return null -} - -export async function writeCredentialsLegacy(credentials: { secret: Uint8Array, token: string }): Promise { - if (!existsSync(configuration.happyHomeDir)) { - await mkdir(configuration.happyHomeDir, { recursive: true }) - } - await writeFile(configuration.privateKeyFile, JSON.stringify({ - secret: encodeBase64(credentials.secret), - token: credentials.token - }, null, 2)); -} - export async function writeCredentialsDataKey(credentials: { publicKey: Uint8Array, machineKey: Uint8Array, token: string }): Promise { if (!existsSync(configuration.happyHomeDir)) { await mkdir(configuration.happyHomeDir, { recursive: true }) diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index 315539bf..89dcab82 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -7,7 +7,7 @@ import chalk from 'chalk' import { configuration } from '@/configuration' -import { readSettings, readCredentials } from '@/persistence' +import { readSettings } from '@/persistence' import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient' import { findRunawayHappyProcesses, findAllHappyProcesses } from '@/daemon/doctor' import { readDaemonState } from '@/persistence' @@ -147,15 +147,6 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(chalk.gray(' Run `hapi auth login` to configure or set CLI_API_TOKEN env var')); } - // Legacy credentials (unused in direct-connect mode) - try { - const credentials = await readCredentials(); - if (credentials) { - console.log(chalk.yellow('⚠️ Legacy credentials file present (unused in direct-connect mode)')); - } - } catch { - // ignore - } } // Daemon status - shown for both 'all' and 'daemon' filters diff --git a/web/src/chat/normalize.ts b/web/src/chat/normalize.ts index 3d67e131..9b44d459 100644 --- a/web/src/chat/normalize.ts +++ b/web/src/chat/normalize.ts @@ -1,366 +1,9 @@ import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { DecryptedMessage } from '@/types/api' -import type { AgentEvent, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types' - -function isObject(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' -} - -function asString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -function asNumber(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -function safeStringify(value: unknown): string { - if (typeof value === 'string') return value - try { - return JSON.stringify(value, null, 2) - } catch { - return String(value) - } -} - -function isSkippableAgentContent(content: unknown): boolean { - if (!isObject(content) || content.type !== 'output') return false - const data = isObject(content.data) ? content.data : null - if (!data) return false - return Boolean(data.isMeta) || Boolean(data.isCompactSummary) -} - -function isCodexContent(content: unknown): boolean { - return isObject(content) && content.type === 'codex' -} - -function normalizeToolResultPermissions(value: unknown): ToolResultPermission | undefined { - if (!isObject(value)) return undefined - const date = asNumber(value.date) - const result = value.result - if (date === null) return undefined - if (result !== 'approved' && result !== 'denied') return undefined - - const mode = asString(value.mode) ?? undefined - const allowedTools = Array.isArray(value.allowedTools) - ? value.allowedTools.filter((tool) => typeof tool === 'string') - : undefined - const decision = value.decision - const normalizedDecision = decision === 'approved' || decision === 'approved_for_session' || decision === 'denied' || decision === 'abort' - ? decision - : undefined - - return { - date, - result, - mode, - allowedTools, - decision: normalizedDecision - } -} - -function normalizeAgentEvent(value: unknown): AgentEvent | null { - if (!isObject(value) || typeof value.type !== 'string') return null - return value as AgentEvent -} - -function normalizeAssistantOutput( - messageId: string, - localId: string | null, - createdAt: number, - data: Record, - meta?: unknown -): NormalizedMessage | null { - const uuid = asString(data.uuid) ?? messageId - const parentUUID = asString(data.parentUuid) ?? null - const isSidechain = Boolean(data.isSidechain) - - const message = isObject(data.message) ? data.message : null - if (!message) return null - - const modelContent = message.content - const blocks: NormalizedAgentContent[] = [] - - if (typeof modelContent === 'string') { - blocks.push({ type: 'text', text: modelContent, uuid, parentUUID }) - } else if (Array.isArray(modelContent)) { - for (const block of modelContent) { - if (!isObject(block) || typeof block.type !== 'string') continue - if (block.type === 'text' && typeof block.text === 'string') { - blocks.push({ type: 'text', text: block.text, uuid, parentUUID }) - continue - } - if (block.type === 'thinking' && typeof block.thinking === 'string') { - blocks.push({ type: 'reasoning', text: block.thinking, uuid, parentUUID }) - continue - } - if (block.type === 'tool_use' && typeof block.id === 'string') { - const name = asString(block.name) ?? 'Tool' - const input = 'input' in block ? (block as Record).input : undefined - const description = isObject(input) && typeof input.description === 'string' ? input.description : null - blocks.push({ type: 'tool-call', id: block.id, name, input, description, uuid, parentUUID }) - } - } - } - - const usage = isObject(message.usage) ? (message.usage as Record) : null - const inputTokens = usage ? asNumber(usage.input_tokens) : null - const outputTokens = usage ? asNumber(usage.output_tokens) : null - - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain, - content: blocks, - meta, - usage: inputTokens !== null && outputTokens !== null ? { - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: asNumber(usage?.cache_creation_input_tokens) ?? undefined, - cache_read_input_tokens: asNumber(usage?.cache_read_input_tokens) ?? undefined, - service_tier: asString(usage?.service_tier) ?? undefined - } : undefined - } -} - -function normalizeUserOutput( - messageId: string, - localId: string | null, - createdAt: number, - data: Record, - meta?: unknown -): NormalizedMessage | null { - const uuid = asString(data.uuid) ?? messageId - const parentUUID = asString(data.parentUuid) ?? null - const isSidechain = Boolean(data.isSidechain) - - const message = isObject(data.message) ? data.message : null - if (!message) return null - - const messageContent = message.content - - if (isSidechain && typeof messageContent === 'string') { - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain: true, - content: [{ type: 'sidechain', uuid, prompt: messageContent }] - } - } - - if (typeof messageContent === 'string') { - return { - id: messageId, - localId, - createdAt, - role: 'user', - isSidechain: false, - content: { type: 'text', text: messageContent }, - meta - } - } - - const blocks: NormalizedAgentContent[] = [] - - if (Array.isArray(messageContent)) { - for (const block of messageContent) { - if (!isObject(block) || typeof block.type !== 'string') continue - if (block.type === 'text' && typeof block.text === 'string') { - blocks.push({ type: 'text', text: block.text, uuid, parentUUID }) - continue - } - if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { - const isError = Boolean(block.is_error) - const rawContent = 'content' in block ? (block as Record).content : undefined - const embeddedToolUseResult = 'toolUseResult' in data ? (data as Record).toolUseResult : null - - const permissions = normalizeToolResultPermissions(block.permissions) - - blocks.push({ - type: 'tool-result', - tool_use_id: block.tool_use_id, - content: embeddedToolUseResult ?? rawContent, - is_error: isError, - uuid, - parentUUID, - permissions - }) - } - } - } - - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain, - content: blocks, - meta - } -} - -function normalizeAgentRecord( - messageId: string, - localId: string | null, - createdAt: number, - content: unknown, - meta?: unknown -): NormalizedMessage | null { - if (!isObject(content) || typeof content.type !== 'string') return null - - if (content.type === 'output') { - const data = isObject(content.data) ? content.data : null - if (!data || typeof data.type !== 'string') return null - - // Skip meta/compact-summary messages (parity with hapi-app) - if (data.isMeta) return null - if (data.isCompactSummary) return null - - if (data.type === 'assistant') { - return normalizeAssistantOutput(messageId, localId, createdAt, data, meta) - } - if (data.type === 'user') { - return normalizeUserOutput(messageId, localId, createdAt, data, meta) - } - if (data.type === 'summary' && typeof data.summary === 'string') { - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ type: 'summary', summary: data.summary }], - meta - } - } - return null - } - - if (content.type === 'event') { - const event = normalizeAgentEvent(content.data) - if (!event) return null - return { - id: messageId, - localId, - createdAt, - role: 'event', - content: event, - isSidechain: false, - meta - } - } - - if (content.type === 'codex') { - const data = isObject(content.data) ? content.data : null - if (!data || typeof data.type !== 'string') return null - - if (data.type === 'message' && typeof data.message === 'string') { - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ type: 'text', text: data.message, uuid: messageId, parentUUID: null }], - meta - } - } - - if (data.type === 'reasoning' && typeof data.message === 'string') { - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }], - meta - } - } - - if (data.type === 'tool-call' && typeof data.callId === 'string') { - const uuid = asString(data.id) ?? messageId - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-call', - id: data.callId, - name: asString(data.name) ?? 'unknown', - input: data.input, - description: null, - uuid, - parentUUID: null - }], - meta - } - } - - if (data.type === 'tool-call-result' && typeof data.callId === 'string') { - const uuid = asString(data.id) ?? messageId - return { - id: messageId, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-result', - tool_use_id: data.callId, - content: data.output, - is_error: false, - uuid, - parentUUID: null - }], - meta - } - } - } - - return null -} - -function normalizeUserRecord( - messageId: string, - localId: string | null, - createdAt: number, - content: unknown, - meta?: unknown -): NormalizedMessage | null { - if (typeof content === 'string') { - return { - id: messageId, - localId, - createdAt, - role: 'user', - content: { type: 'text', text: content }, - isSidechain: false, - meta - } - } - - if (isObject(content) && content.type === 'text' && typeof content.text === 'string') { - return { - id: messageId, - localId, - createdAt, - role: 'user', - content: { type: 'text', text: content.text }, - isSidechain: false, - meta - } - } - - return null -} +import type { NormalizedMessage } from '@/chat/types' +import { safeStringify } from '@/chat/normalizeUtils' +import { isCodexContent, isSkippableAgentContent, normalizeAgentRecord } from '@/chat/normalizeAgent' +import { normalizeUserRecord } from '@/chat/normalizeUser' export function normalizeDecryptedMessage(message: DecryptedMessage): NormalizedMessage | null { const record = unwrapRoleWrappedRecordEnvelope(message.content) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts new file mode 100644 index 00000000..356a6d3b --- /dev/null +++ b/web/src/chat/normalizeAgent.ts @@ -0,0 +1,307 @@ +import type { AgentEvent, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types' +import { asNumber, asString, isObject } from '@/chat/normalizeUtils' + +function normalizeToolResultPermissions(value: unknown): ToolResultPermission | undefined { + if (!isObject(value)) return undefined + const date = asNumber(value.date) + const result = value.result + if (date === null) return undefined + if (result !== 'approved' && result !== 'denied') return undefined + + const mode = asString(value.mode) ?? undefined + const allowedTools = Array.isArray(value.allowedTools) + ? value.allowedTools.filter((tool) => typeof tool === 'string') + : undefined + const decision = value.decision + const normalizedDecision = decision === 'approved' || decision === 'approved_for_session' || decision === 'denied' || decision === 'abort' + ? decision + : undefined + + return { + date, + result, + mode, + allowedTools, + decision: normalizedDecision + } +} + +function normalizeAgentEvent(value: unknown): AgentEvent | null { + if (!isObject(value) || typeof value.type !== 'string') return null + return value as AgentEvent +} + +function normalizeAssistantOutput( + messageId: string, + localId: string | null, + createdAt: number, + data: Record, + meta?: unknown +): NormalizedMessage | null { + const uuid = asString(data.uuid) ?? messageId + const parentUUID = asString(data.parentUuid) ?? null + const isSidechain = Boolean(data.isSidechain) + + const message = isObject(data.message) ? data.message : null + if (!message) return null + + const modelContent = message.content + const blocks: NormalizedAgentContent[] = [] + + if (typeof modelContent === 'string') { + blocks.push({ type: 'text', text: modelContent, uuid, parentUUID }) + } else if (Array.isArray(modelContent)) { + for (const block of modelContent) { + if (!isObject(block) || typeof block.type !== 'string') continue + if (block.type === 'text' && typeof block.text === 'string') { + blocks.push({ type: 'text', text: block.text, uuid, parentUUID }) + continue + } + if (block.type === 'thinking' && typeof block.thinking === 'string') { + blocks.push({ type: 'reasoning', text: block.thinking, uuid, parentUUID }) + continue + } + if (block.type === 'tool_use' && typeof block.id === 'string') { + const name = asString(block.name) ?? 'Tool' + const input = 'input' in block ? (block as Record).input : undefined + const description = isObject(input) && typeof input.description === 'string' ? input.description : null + blocks.push({ type: 'tool-call', id: block.id, name, input, description, uuid, parentUUID }) + } + } + } + + const usage = isObject(message.usage) ? (message.usage as Record) : null + const inputTokens = usage ? asNumber(usage.input_tokens) : null + const outputTokens = usage ? asNumber(usage.output_tokens) : null + + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain, + content: blocks, + meta, + usage: inputTokens !== null && outputTokens !== null ? { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: asNumber(usage?.cache_creation_input_tokens) ?? undefined, + cache_read_input_tokens: asNumber(usage?.cache_read_input_tokens) ?? undefined, + service_tier: asString(usage?.service_tier) ?? undefined + } : undefined + } +} + +function normalizeUserOutput( + messageId: string, + localId: string | null, + createdAt: number, + data: Record, + meta?: unknown +): NormalizedMessage | null { + const uuid = asString(data.uuid) ?? messageId + const parentUUID = asString(data.parentUuid) ?? null + const isSidechain = Boolean(data.isSidechain) + + const message = isObject(data.message) ? data.message : null + if (!message) return null + + const messageContent = message.content + + if (isSidechain && typeof messageContent === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ type: 'sidechain', uuid, prompt: messageContent }] + } + } + + if (typeof messageContent === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'user', + isSidechain: false, + content: { type: 'text', text: messageContent }, + meta + } + } + + const blocks: NormalizedAgentContent[] = [] + + if (Array.isArray(messageContent)) { + for (const block of messageContent) { + if (!isObject(block) || typeof block.type !== 'string') continue + if (block.type === 'text' && typeof block.text === 'string') { + blocks.push({ type: 'text', text: block.text, uuid, parentUUID }) + continue + } + if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { + const isError = Boolean(block.is_error) + const rawContent = 'content' in block ? (block as Record).content : undefined + const embeddedToolUseResult = 'toolUseResult' in data ? (data as Record).toolUseResult : null + + const permissions = normalizeToolResultPermissions(block.permissions) + + blocks.push({ + type: 'tool-result', + tool_use_id: block.tool_use_id, + content: embeddedToolUseResult ?? rawContent, + is_error: isError, + uuid, + parentUUID, + permissions + }) + } + } + } + + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain, + content: blocks, + meta + } +} + +export function isSkippableAgentContent(content: unknown): boolean { + if (!isObject(content) || content.type !== 'output') return false + const data = isObject(content.data) ? content.data : null + if (!data) return false + return Boolean(data.isMeta) || Boolean(data.isCompactSummary) +} + +export function isCodexContent(content: unknown): boolean { + return isObject(content) && content.type === 'codex' +} + +export function normalizeAgentRecord( + messageId: string, + localId: string | null, + createdAt: number, + content: unknown, + meta?: unknown +): NormalizedMessage | null { + if (!isObject(content) || typeof content.type !== 'string') return null + + if (content.type === 'output') { + const data = isObject(content.data) ? content.data : null + if (!data || typeof data.type !== 'string') return null + + // Skip meta/compact-summary messages (parity with hapi-app) + if (data.isMeta) return null + if (data.isCompactSummary) return null + + if (data.type === 'assistant') { + return normalizeAssistantOutput(messageId, localId, createdAt, data, meta) + } + if (data.type === 'user') { + return normalizeUserOutput(messageId, localId, createdAt, data, meta) + } + if (data.type === 'summary' && typeof data.summary === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'summary', summary: data.summary }], + meta + } + } + return null + } + + if (content.type === 'event') { + const event = normalizeAgentEvent(content.data) + if (!event) return null + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: event, + isSidechain: false, + meta + } + } + + if (content.type === 'codex') { + const data = isObject(content.data) ? content.data : null + if (!data || typeof data.type !== 'string') return null + + if (data.type === 'message' && typeof data.message === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'text', text: data.message, uuid: messageId, parentUUID: null }], + meta + } + } + + if (data.type === 'reasoning' && typeof data.message === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }], + meta + } + } + + if (data.type === 'tool-call' && typeof data.callId === 'string') { + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: data.callId, + name: asString(data.name) ?? 'unknown', + input: data.input, + description: null, + uuid, + parentUUID: null + }], + meta + } + } + + if (data.type === 'tool-call-result' && typeof data.callId === 'string') { + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: data.callId, + content: data.output, + is_error: false, + uuid, + parentUUID: null + }], + meta + } + } + } + + return null +} diff --git a/web/src/chat/normalizeUser.ts b/web/src/chat/normalizeUser.ts new file mode 100644 index 00000000..919e8745 --- /dev/null +++ b/web/src/chat/normalizeUser.ts @@ -0,0 +1,36 @@ +import type { NormalizedMessage } from '@/chat/types' +import { isObject } from '@/chat/normalizeUtils' + +export function normalizeUserRecord( + messageId: string, + localId: string | null, + createdAt: number, + content: unknown, + meta?: unknown +): NormalizedMessage | null { + if (typeof content === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'user', + content: { type: 'text', text: content }, + isSidechain: false, + meta + } + } + + if (isObject(content) && content.type === 'text' && typeof content.text === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'user', + content: { type: 'text', text: content.text }, + isSidechain: false, + meta + } + } + + return null +} diff --git a/web/src/chat/normalizeUtils.ts b/web/src/chat/normalizeUtils.ts new file mode 100644 index 00000000..be1cf589 --- /dev/null +++ b/web/src/chat/normalizeUtils.ts @@ -0,0 +1,20 @@ +export function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +export function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +export function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function safeStringify(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 66b5574b..291f348d 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,563 +1,15 @@ import type { AgentState } from '@/types/api' -import type { AgentEvent, ChatBlock, ChatToolCall, CliOutputBlock, NormalizedMessage, ToolCallBlock, ToolPermission, UsageData } from '@/chat/types' +import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' import { traceMessages, type TracedMessage } from '@/chat/tracer' - -const CLI_TAG_REGEX = /<(?:local-command-[a-z-]+|command-(?:name|message|args))>/i -const CLI_COMMAND_NAME_REGEX = //i -const CLI_COMMAND_STDOUT_REGEX = //i +import { dedupeAgentEvents } from '@/chat/reducerEvents' +import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' +import { reduceTimeline } from '@/chat/reducerTimeline' // Calculate context size from usage data function calculateContextSize(usage: UsageData): number { return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens } -function parseClaudeUsageLimit(text: string): number | null { - const match = text.match(/^Claude AI usage limit reached\|(\d+)$/) - if (!match) return null - const timestamp = Number.parseInt(match[1], 10) - if (!Number.isFinite(timestamp)) return null - return timestamp -} - -function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null { - if (msg.isSidechain) return null - if (msg.role !== 'agent') return null - - for (const content of msg.content) { - if (content.type === 'text') { - const limitReached = parseClaudeUsageLimit(content.text) - if (limitReached !== null) { - return { type: 'limit-reached', endsAt: limitReached } - } - } - } - - return null -} - -function extractTitleFromChangeTitleInput(input: unknown): string | null { - if (!input || typeof input !== 'object') return null - const title = (input as { title?: unknown }).title - return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null -} - -function collectTitleChanges(messages: NormalizedMessage[]): Map { - const map = new Map() - for (const msg of messages) { - if (msg.role !== 'agent') continue - for (const content of msg.content) { - if (content.type !== 'tool-call') continue - if (content.name !== 'mcp__hapi__change_title') continue - const title = extractTitleFromChangeTitleInput(content.input) - if (!title) continue - map.set(content.id, title) - } - } - return map -} - -function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] { - const result: ChatBlock[] = [] - let prevEventKey: string | null = null - let prevTitleChangedTo: string | null = null - - for (const block of blocks) { - if (block.kind !== 'agent-event') { - result.push(block) - prevEventKey = null - prevTitleChangedTo = null - continue - } - - const event = block.event as { type: string; [key: string]: unknown } - if (event.type === 'title-changed' && typeof event.title === 'string') { - const title = event.title.trim() - const key = `title-changed:${title}` - if (key === prevEventKey) { - continue - } - result.push(block) - prevEventKey = key - prevTitleChangedTo = title - continue - } - - if (event.type === 'message' && typeof event.message === 'string') { - const message = event.message.trim() - const key = `message:${message}` - if (key === prevEventKey) { - continue - } - if (prevTitleChangedTo && message === prevTitleChangedTo) { - continue - } - result.push(block) - prevEventKey = key - prevTitleChangedTo = null - continue - } - - let key: string - try { - key = `event:${JSON.stringify(event)}` - } catch { - key = `event:${String(event.type)}` - } - - if (key === prevEventKey) { - continue - } - - result.push(block) - prevEventKey = key - prevTitleChangedTo = null - } - - return result -} - -type PermissionEntry = { - toolName: string - input: unknown - permission: ToolPermission -} - -function getPermissions(agentState: AgentState | null | undefined): Map { - const map = new Map() - - const completed = agentState?.completedRequests ?? null - if (completed) { - for (const [id, entry] of Object.entries(completed)) { - map.set(id, { - toolName: entry.tool, - input: entry.arguments, - permission: { - id, - status: entry.status, - reason: entry.reason ?? undefined, - mode: entry.mode ?? undefined, - decision: entry.decision ?? undefined, - allowedTools: entry.allowTools, - answers: entry.answers, - createdAt: entry.createdAt ?? null, - completedAt: entry.completedAt ?? null - } - }) - } - } - - const requests = agentState?.requests ?? null - if (requests) { - for (const [id, request] of Object.entries(requests)) { - if (map.has(id)) continue - map.set(id, { - toolName: request.tool, - input: request.arguments, - permission: { - id, - status: 'pending', - createdAt: request.createdAt ?? null - } - }) - } - } - - return map -} - -function getMetaSentFrom(meta: unknown): string | null { - if (!meta || typeof meta !== 'object') return null - const sentFrom = (meta as { sentFrom?: unknown }).sentFrom - return typeof sentFrom === 'string' ? sentFrom : null -} - -function hasCliOutputTags(text: string): boolean { - return CLI_TAG_REGEX.test(text) -} - -function hasCommandNameTag(text: string): boolean { - return CLI_COMMAND_NAME_REGEX.test(text) -} - -function hasLocalCommandStdoutTag(text: string): boolean { - return CLI_COMMAND_STDOUT_REGEX.test(text) -} - -function isCliOutputText(text: string, meta: unknown): boolean { - return getMetaSentFrom(meta) === 'cli' && hasCliOutputTags(text) -} - -function createCliOutputBlock(props: { - id: string - localId: string | null - createdAt: number - text: string - source: CliOutputBlock['source'] - meta?: unknown -}): CliOutputBlock { - return { - kind: 'cli-output', - id: props.id, - localId: props.localId, - createdAt: props.createdAt, - text: props.text, - source: props.source, - meta: props.meta - } -} - -function mergeCliOutputBlocks(blocks: ChatBlock[]): ChatBlock[] { - const merged: ChatBlock[] = [] - - for (const block of blocks) { - if (block.kind !== 'cli-output') { - merged.push(block) - continue - } - - const prev = merged[merged.length - 1] - if ( - prev - && prev.kind === 'cli-output' - && prev.source === block.source - && hasCommandNameTag(prev.text) - && !hasLocalCommandStdoutTag(prev.text) - && hasLocalCommandStdoutTag(block.text) - ) { - const separator = prev.text.endsWith('\n') || block.text.startsWith('\n') ? '' : '\n' - merged[merged.length - 1] = { ...prev, text: `${prev.text}${separator}${block.text}` } - continue - } - - merged.push(block) - } - - return merged -} - -function ensureToolBlock( - blocks: ChatBlock[], - toolBlocksById: Map, - id: string, - seed: { - createdAt: number - localId: string | null - meta?: unknown - name: string - input: unknown - description: string | null - permission?: ToolPermission - } -): ToolCallBlock { - const existing = toolBlocksById.get(id) - if (existing) { - const isPlaceholderToolName = (name: string): boolean => { - const normalized = name.trim().toLowerCase() - return normalized === '' || normalized === 'tool' || normalized === 'unknown' - } - - // Preserve earliest createdAt for stable ordering. - if (seed.createdAt < existing.createdAt) { - existing.createdAt = seed.createdAt - existing.tool.createdAt = seed.createdAt - } - if (seed.permission) { - existing.tool.permission = { ...existing.tool.permission, ...seed.permission } - if (existing.tool.state === 'running' && seed.permission.status === 'pending') { - existing.tool.state = 'pending' - } - } - if (seed.name && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name))) { - existing.tool.name = seed.name - } - if (seed.input !== null && seed.input !== undefined) { - existing.tool.input = seed.input - } - if (seed.description !== null) { - existing.tool.description = seed.description - } - return existing - } - - const initialState: ChatToolCall['state'] = seed.permission?.status === 'pending' - ? 'pending' - : seed.permission?.status === 'denied' || seed.permission?.status === 'canceled' - ? 'error' - : 'running' - - const tool: ChatToolCall = { - id, - name: seed.name, - state: initialState, - input: seed.input, - createdAt: seed.createdAt, - startedAt: initialState === 'running' ? seed.createdAt : null, - completedAt: null, - description: seed.description, - permission: seed.permission - } - - const block: ToolCallBlock = { - kind: 'tool-call', - id, - localId: seed.localId, - createdAt: seed.createdAt, - tool, - children: [], - meta: seed.meta - } - - toolBlocksById.set(id, block) - blocks.push(block) - return block -} - -function collectToolIdsFromMessages(messages: NormalizedMessage[]): Set { - const ids = new Set() - for (const msg of messages) { - if (msg.role !== 'agent') continue - for (const content of msg.content) { - if (content.type === 'tool-call') { - ids.add(content.id) - } else if (content.type === 'tool-result') { - ids.add(content.tool_use_id) - } - } - } - return ids -} - -function reduceTimeline( - messages: TracedMessage[], - context: { - permissionsById: Map - groups: Map - consumedGroupIds: Set - titleChangesByToolUseId: Map - emittedTitleChangeToolUseIds: Set - } -): { blocks: ChatBlock[]; toolBlocksById: Map; hasReadyEvent: boolean } { - const blocks: ChatBlock[] = [] - const toolBlocksById = new Map() - let hasReadyEvent = false - - for (const msg of messages) { - if (msg.role === 'event') { - if (msg.content.type === 'ready') { - hasReadyEvent = true - continue - } - blocks.push({ - kind: 'agent-event', - id: msg.id, - createdAt: msg.createdAt, - event: msg.content, - meta: msg.meta - }) - continue - } - - const event = parseMessageAsEvent(msg) - if (event) { - blocks.push({ - kind: 'agent-event', - id: msg.id, - createdAt: msg.createdAt, - event, - meta: msg.meta - }) - continue - } - - if (msg.role === 'user') { - if (isCliOutputText(msg.content.text, msg.meta)) { - blocks.push(createCliOutputBlock({ - id: msg.id, - localId: msg.localId, - createdAt: msg.createdAt, - text: msg.content.text, - source: 'user', - meta: msg.meta - })) - continue - } - blocks.push({ - kind: 'user-text', - id: msg.id, - localId: msg.localId, - createdAt: msg.createdAt, - text: msg.content.text, - status: msg.status, - originalText: msg.originalText, - meta: msg.meta - }) - continue - } - - if (msg.role === 'agent') { - for (let idx = 0; idx < msg.content.length; idx += 1) { - const c = msg.content[idx] - if (c.type === 'text') { - if (isCliOutputText(c.text, msg.meta)) { - blocks.push(createCliOutputBlock({ - id: `${msg.id}:${idx}`, - localId: msg.localId, - createdAt: msg.createdAt, - text: c.text, - source: 'assistant', - meta: msg.meta - })) - continue - } - blocks.push({ - kind: 'agent-text', - id: `${msg.id}:${idx}`, - localId: msg.localId, - createdAt: msg.createdAt, - text: c.text, - meta: msg.meta - }) - continue - } - - if (c.type === 'reasoning') { - blocks.push({ - kind: 'agent-reasoning', - id: `${msg.id}:${idx}`, - localId: msg.localId, - createdAt: msg.createdAt, - text: c.text, - meta: msg.meta - }) - continue - } - - if (c.type === 'summary') { - blocks.push({ - kind: 'agent-event', - id: `${msg.id}:${idx}`, - createdAt: msg.createdAt, - event: { type: 'message', message: c.summary }, - meta: msg.meta - }) - continue - } - - if (c.type === 'tool-call') { - if (c.name === 'mcp__hapi__change_title') { - const title = context.titleChangesByToolUseId.get(c.id) ?? extractTitleFromChangeTitleInput(c.input) - if (title && !context.emittedTitleChangeToolUseIds.has(c.id)) { - context.emittedTitleChangeToolUseIds.add(c.id) - blocks.push({ - kind: 'agent-event', - id: `${msg.id}:${idx}`, - createdAt: msg.createdAt, - event: { type: 'title-changed', title }, - meta: msg.meta - }) - } - continue - } - - const permission = context.permissionsById.get(c.id)?.permission - - const block = ensureToolBlock(blocks, toolBlocksById, c.id, { - createdAt: msg.createdAt, - localId: msg.localId, - meta: msg.meta, - name: c.name, - input: c.input, - description: c.description, - permission - }) - - if (block.tool.state === 'pending') { - block.tool.state = 'running' - block.tool.startedAt = msg.createdAt - } - - if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) { - const sidechain = context.groups.get(msg.id) ?? null - if (sidechain && sidechain.length > 0) { - context.consumedGroupIds.add(msg.id) - const child = reduceTimeline(sidechain, context) - hasReadyEvent = hasReadyEvent || child.hasReadyEvent - block.children = child.blocks - } - } - continue - } - - if (c.type === 'tool-result') { - const title = context.titleChangesByToolUseId.get(c.tool_use_id) ?? null - if (title) { - if (!context.emittedTitleChangeToolUseIds.has(c.tool_use_id)) { - context.emittedTitleChangeToolUseIds.add(c.tool_use_id) - blocks.push({ - kind: 'agent-event', - id: `${msg.id}:${idx}`, - createdAt: msg.createdAt, - event: { type: 'title-changed', title }, - meta: msg.meta - }) - } - continue - } - - const permissionEntry = context.permissionsById.get(c.tool_use_id) - const permissionFromResult = c.permissions ? ({ - id: c.tool_use_id, - status: c.permissions.result === 'approved' ? 'approved' : 'denied', - date: c.permissions.date, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision - } satisfies ToolPermission) : undefined - - const permission = (() => { - if (permissionFromResult && permissionEntry?.permission) { - return { - ...permissionEntry.permission, - ...permissionFromResult, - allowedTools: permissionFromResult.allowedTools ?? permissionEntry.permission.allowedTools, - decision: permissionFromResult.decision ?? permissionEntry.permission.decision - } satisfies ToolPermission - } - return permissionFromResult ?? permissionEntry?.permission - })() - - const block = ensureToolBlock(blocks, toolBlocksById, c.tool_use_id, { - createdAt: msg.createdAt, - localId: msg.localId, - meta: msg.meta, - name: permissionEntry?.toolName ?? 'Tool', - input: permissionEntry?.input ?? null, - description: null, - permission - }) - - block.tool.result = c.content - block.tool.completedAt = msg.createdAt - block.tool.state = c.is_error ? 'error' : 'completed' - continue - } - - if (c.type === 'sidechain') { - blocks.push({ - kind: 'user-text', - id: `${msg.id}:${idx}`, - localId: null, - createdAt: msg.createdAt, - text: c.prompt - }) - } - } - } - } - - return { blocks: mergeCliOutputBlocks(blocks), toolBlocksById, hasReadyEvent } -} - export type LatestUsage = { inputTokens: number outputTokens: number diff --git a/web/src/chat/reducerCliOutput.ts b/web/src/chat/reducerCliOutput.ts new file mode 100644 index 00000000..3fa9da82 --- /dev/null +++ b/web/src/chat/reducerCliOutput.ts @@ -0,0 +1,75 @@ +import type { ChatBlock, CliOutputBlock } from '@/chat/types' + +const CLI_TAG_REGEX = /<(?:local-command-[a-z-]+|command-(?:name|message|args))>/i +const CLI_COMMAND_NAME_REGEX = //i +const CLI_COMMAND_STDOUT_REGEX = //i + +function getMetaSentFrom(meta: unknown): string | null { + if (!meta || typeof meta !== 'object') return null + const sentFrom = (meta as { sentFrom?: unknown }).sentFrom + return typeof sentFrom === 'string' ? sentFrom : null +} + +function hasCliOutputTags(text: string): boolean { + return CLI_TAG_REGEX.test(text) +} + +function hasCommandNameTag(text: string): boolean { + return CLI_COMMAND_NAME_REGEX.test(text) +} + +function hasLocalCommandStdoutTag(text: string): boolean { + return CLI_COMMAND_STDOUT_REGEX.test(text) +} + +export function isCliOutputText(text: string, meta: unknown): boolean { + return getMetaSentFrom(meta) === 'cli' && hasCliOutputTags(text) +} + +export function createCliOutputBlock(props: { + id: string + localId: string | null + createdAt: number + text: string + source: CliOutputBlock['source'] + meta?: unknown +}): CliOutputBlock { + return { + kind: 'cli-output', + id: props.id, + localId: props.localId, + createdAt: props.createdAt, + text: props.text, + source: props.source, + meta: props.meta + } +} + +export function mergeCliOutputBlocks(blocks: ChatBlock[]): ChatBlock[] { + const merged: ChatBlock[] = [] + + for (const block of blocks) { + if (block.kind !== 'cli-output') { + merged.push(block) + continue + } + + const prev = merged[merged.length - 1] + if ( + prev + && prev.kind === 'cli-output' + && prev.source === block.source + && hasCommandNameTag(prev.text) + && !hasLocalCommandStdoutTag(prev.text) + && hasLocalCommandStdoutTag(block.text) + ) { + const separator = prev.text.endsWith('\n') || block.text.startsWith('\n') ? '' : '\n' + merged[merged.length - 1] = { ...prev, text: `${prev.text}${separator}${block.text}` } + continue + } + + merged.push(block) + } + + return merged +} diff --git a/web/src/chat/reducerEvents.ts b/web/src/chat/reducerEvents.ts new file mode 100644 index 00000000..66f9a718 --- /dev/null +++ b/web/src/chat/reducerEvents.ts @@ -0,0 +1,85 @@ +import type { AgentEvent, ChatBlock, NormalizedMessage } from '@/chat/types' + +function parseClaudeUsageLimit(text: string): number | null { + const match = text.match(/^Claude AI usage limit reached\|(\d+)$/) + if (!match) return null + const timestamp = Number.parseInt(match[1], 10) + if (!Number.isFinite(timestamp)) return null + return timestamp +} + +export function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null { + if (msg.isSidechain) return null + if (msg.role !== 'agent') return null + + for (const content of msg.content) { + if (content.type === 'text') { + const limitReached = parseClaudeUsageLimit(content.text) + if (limitReached !== null) { + return { type: 'limit-reached', endsAt: limitReached } + } + } + } + + return null +} + +export function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] { + const result: ChatBlock[] = [] + let prevEventKey: string | null = null + let prevTitleChangedTo: string | null = null + + for (const block of blocks) { + if (block.kind !== 'agent-event') { + result.push(block) + prevEventKey = null + prevTitleChangedTo = null + continue + } + + const event = block.event as { type: string; [key: string]: unknown } + if (event.type === 'title-changed' && typeof event.title === 'string') { + const title = event.title.trim() + const key = `title-changed:${title}` + if (key === prevEventKey) { + continue + } + result.push(block) + prevEventKey = key + prevTitleChangedTo = title + continue + } + + if (event.type === 'message' && typeof event.message === 'string') { + const message = event.message.trim() + const key = `message:${message}` + if (key === prevEventKey) { + continue + } + if (prevTitleChangedTo && message === prevTitleChangedTo) { + continue + } + result.push(block) + prevEventKey = key + prevTitleChangedTo = null + continue + } + + let key: string + try { + key = `event:${JSON.stringify(event)}` + } catch { + key = `event:${String(event.type)}` + } + + if (key === prevEventKey) { + continue + } + + result.push(block) + prevEventKey = key + prevTitleChangedTo = null + } + + return result +} diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts new file mode 100644 index 00000000..d4bffe97 --- /dev/null +++ b/web/src/chat/reducerTimeline.ts @@ -0,0 +1,236 @@ +import type { ChatBlock, ToolCallBlock, ToolPermission } from '@/chat/types' +import type { TracedMessage } from '@/chat/tracer' +import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/chat/reducerCliOutput' +import { parseMessageAsEvent } from '@/chat/reducerEvents' +import { ensureToolBlock, extractTitleFromChangeTitleInput, type PermissionEntry } from '@/chat/reducerTools' + +export function reduceTimeline( + messages: TracedMessage[], + context: { + permissionsById: Map + groups: Map + consumedGroupIds: Set + titleChangesByToolUseId: Map + emittedTitleChangeToolUseIds: Set + } +): { blocks: ChatBlock[]; toolBlocksById: Map; hasReadyEvent: boolean } { + const blocks: ChatBlock[] = [] + const toolBlocksById = new Map() + let hasReadyEvent = false + + for (const msg of messages) { + if (msg.role === 'event') { + if (msg.content.type === 'ready') { + hasReadyEvent = true + continue + } + blocks.push({ + kind: 'agent-event', + id: msg.id, + createdAt: msg.createdAt, + event: msg.content, + meta: msg.meta + }) + continue + } + + const event = parseMessageAsEvent(msg) + if (event) { + blocks.push({ + kind: 'agent-event', + id: msg.id, + createdAt: msg.createdAt, + event, + meta: msg.meta + }) + continue + } + + if (msg.role === 'user') { + if (isCliOutputText(msg.content.text, msg.meta)) { + blocks.push(createCliOutputBlock({ + id: msg.id, + localId: msg.localId, + createdAt: msg.createdAt, + text: msg.content.text, + source: 'user', + meta: msg.meta + })) + continue + } + blocks.push({ + kind: 'user-text', + id: msg.id, + localId: msg.localId, + createdAt: msg.createdAt, + text: msg.content.text, + status: msg.status, + originalText: msg.originalText, + meta: msg.meta + }) + continue + } + + if (msg.role === 'agent') { + for (let idx = 0; idx < msg.content.length; idx += 1) { + const c = msg.content[idx] + if (c.type === 'text') { + if (isCliOutputText(c.text, msg.meta)) { + blocks.push(createCliOutputBlock({ + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.text, + source: 'assistant', + meta: msg.meta + })) + continue + } + blocks.push({ + kind: 'agent-text', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.text, + meta: msg.meta + }) + continue + } + + if (c.type === 'reasoning') { + blocks.push({ + kind: 'agent-reasoning', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.text, + meta: msg.meta + }) + continue + } + + if (c.type === 'summary') { + blocks.push({ + kind: 'agent-event', + id: `${msg.id}:${idx}`, + createdAt: msg.createdAt, + event: { type: 'message', message: c.summary }, + meta: msg.meta + }) + continue + } + + if (c.type === 'tool-call') { + if (c.name === 'mcp__hapi__change_title') { + const title = context.titleChangesByToolUseId.get(c.id) ?? extractTitleFromChangeTitleInput(c.input) + if (title && !context.emittedTitleChangeToolUseIds.has(c.id)) { + context.emittedTitleChangeToolUseIds.add(c.id) + blocks.push({ + kind: 'agent-event', + id: `${msg.id}:${idx}`, + createdAt: msg.createdAt, + event: { type: 'title-changed', title }, + meta: msg.meta + }) + } + continue + } + + const permission = context.permissionsById.get(c.id)?.permission + + const block = ensureToolBlock(blocks, toolBlocksById, c.id, { + createdAt: msg.createdAt, + localId: msg.localId, + meta: msg.meta, + name: c.name, + input: c.input, + description: c.description, + permission + }) + + if (block.tool.state === 'pending') { + block.tool.state = 'running' + block.tool.startedAt = msg.createdAt + } + + if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) { + const sidechain = context.groups.get(msg.id) ?? null + if (sidechain && sidechain.length > 0) { + context.consumedGroupIds.add(msg.id) + const child = reduceTimeline(sidechain, context) + hasReadyEvent = hasReadyEvent || child.hasReadyEvent + block.children = child.blocks + } + } + continue + } + + if (c.type === 'tool-result') { + const title = context.titleChangesByToolUseId.get(c.tool_use_id) ?? null + if (title) { + if (!context.emittedTitleChangeToolUseIds.has(c.tool_use_id)) { + context.emittedTitleChangeToolUseIds.add(c.tool_use_id) + blocks.push({ + kind: 'agent-event', + id: `${msg.id}:${idx}`, + createdAt: msg.createdAt, + event: { type: 'title-changed', title }, + meta: msg.meta + }) + } + continue + } + + const permissionEntry = context.permissionsById.get(c.tool_use_id) + const permissionFromResult = c.permissions ? ({ + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision + } satisfies ToolPermission) : undefined + + const permission = (() => { + if (permissionFromResult && permissionEntry?.permission) { + return { + ...permissionEntry.permission, + ...permissionFromResult, + allowedTools: permissionFromResult.allowedTools ?? permissionEntry.permission.allowedTools, + decision: permissionFromResult.decision ?? permissionEntry.permission.decision + } satisfies ToolPermission + } + return permissionFromResult ?? permissionEntry?.permission + })() + + const block = ensureToolBlock(blocks, toolBlocksById, c.tool_use_id, { + createdAt: msg.createdAt, + localId: msg.localId, + meta: msg.meta, + name: permissionEntry?.toolName ?? 'Tool', + input: permissionEntry?.input ?? null, + description: null, + permission + }) + + block.tool.result = c.content + block.tool.completedAt = msg.createdAt + block.tool.state = c.is_error ? 'error' : 'completed' + continue + } + + if (c.type === 'sidechain') { + blocks.push({ + kind: 'user-text', + id: `${msg.id}:${idx}`, + localId: null, + createdAt: msg.createdAt, + text: c.prompt + }) + } + } + } + } + + return { blocks: mergeCliOutputBlocks(blocks), toolBlocksById, hasReadyEvent } +} diff --git a/web/src/chat/reducerTools.ts b/web/src/chat/reducerTools.ts new file mode 100644 index 00000000..605f3716 --- /dev/null +++ b/web/src/chat/reducerTools.ts @@ -0,0 +1,164 @@ +import type { AgentState } from '@/types/api' +import type { ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission } from '@/chat/types' + +export type PermissionEntry = { + toolName: string + input: unknown + permission: ToolPermission +} + +export function getPermissions(agentState: AgentState | null | undefined): Map { + const map = new Map() + + const completed = agentState?.completedRequests ?? null + if (completed) { + for (const [id, entry] of Object.entries(completed)) { + map.set(id, { + toolName: entry.tool, + input: entry.arguments, + permission: { + id, + status: entry.status, + reason: entry.reason ?? undefined, + mode: entry.mode ?? undefined, + decision: entry.decision ?? undefined, + allowedTools: entry.allowTools, + answers: entry.answers, + createdAt: entry.createdAt ?? null, + completedAt: entry.completedAt ?? null + } + }) + } + } + + const requests = agentState?.requests ?? null + if (requests) { + for (const [id, request] of Object.entries(requests)) { + if (map.has(id)) continue + map.set(id, { + toolName: request.tool, + input: request.arguments, + permission: { + id, + status: 'pending', + createdAt: request.createdAt ?? null + } + }) + } + } + + return map +} + +export function ensureToolBlock( + blocks: ChatBlock[], + toolBlocksById: Map, + id: string, + seed: { + createdAt: number + localId: string | null + meta?: unknown + name: string + input: unknown + description: string | null + permission?: ToolPermission + } +): ToolCallBlock { + const existing = toolBlocksById.get(id) + if (existing) { + const isPlaceholderToolName = (name: string): boolean => { + const normalized = name.trim().toLowerCase() + return normalized === '' || normalized === 'tool' || normalized === 'unknown' + } + + // Preserve earliest createdAt for stable ordering. + if (seed.createdAt < existing.createdAt) { + existing.createdAt = seed.createdAt + existing.tool.createdAt = seed.createdAt + } + if (seed.permission) { + existing.tool.permission = { ...existing.tool.permission, ...seed.permission } + if (existing.tool.state === 'running' && seed.permission.status === 'pending') { + existing.tool.state = 'pending' + } + } + if (seed.name && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name))) { + existing.tool.name = seed.name + } + if (seed.input !== null && seed.input !== undefined) { + existing.tool.input = seed.input + } + if (seed.description !== null) { + existing.tool.description = seed.description + } + return existing + } + + const initialState: ChatToolCall['state'] = seed.permission?.status === 'pending' + ? 'pending' + : seed.permission?.status === 'denied' || seed.permission?.status === 'canceled' + ? 'error' + : 'running' + + const tool: ChatToolCall = { + id, + name: seed.name, + state: initialState, + input: seed.input, + createdAt: seed.createdAt, + startedAt: initialState === 'running' ? seed.createdAt : null, + completedAt: null, + description: seed.description, + permission: seed.permission + } + + const block: ToolCallBlock = { + kind: 'tool-call', + id, + localId: seed.localId, + createdAt: seed.createdAt, + tool, + children: [], + meta: seed.meta + } + + toolBlocksById.set(id, block) + blocks.push(block) + return block +} + +export function collectToolIdsFromMessages(messages: NormalizedMessage[]): Set { + const ids = new Set() + for (const msg of messages) { + if (msg.role !== 'agent') continue + for (const content of msg.content) { + if (content.type === 'tool-call') { + ids.add(content.id) + } else if (content.type === 'tool-result') { + ids.add(content.tool_use_id) + } + } + } + return ids +} + +export function extractTitleFromChangeTitleInput(input: unknown): string | null { + if (!input || typeof input !== 'object') return null + const title = (input as { title?: unknown }).title + return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null +} + +export function collectTitleChanges(messages: NormalizedMessage[]): Map { + const map = new Map() + for (const msg of messages) { + if (msg.role !== 'agent') continue + for (const content of msg.content) { + if (content.type !== 'tool-call') continue + if (content.name !== 'mcp__hapi__change_title') continue + const title = extractTitleFromChangeTitleInput(content.input) + if (!title) continue + map.set(content.id, title) + } + } + return map +} diff --git a/web/src/components/NewSession.tsx b/web/src/components/NewSession.tsx deleted file mode 100644 index d294f4d0..00000000 --- a/web/src/components/NewSession.tsx +++ /dev/null @@ -1,473 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' -import type { ApiClient } from '@/api/client' -import type { Machine } from '@/types/api' -import { Autocomplete } from '@/components/ChatInput/Autocomplete' -import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' -import { Button } from '@/components/ui/button' -import { Spinner } from '@/components/Spinner' -import { usePlatform } from '@/hooks/usePlatform' -import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' -import { useSessions } from '@/hooks/queries/useSessions' -import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' -import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' -import { useRecentPaths } from '@/hooks/useRecentPaths' - -type AgentType = 'claude' | 'codex' | 'gemini' -type SessionType = 'simple' | 'worktree' - -function getMachineTitle(machine: Machine): string { - if (machine.metadata?.displayName) return machine.metadata.displayName - if (machine.metadata?.host) return machine.metadata.host - return machine.id.slice(0, 8) -} - -export function NewSession(props: { - api: ApiClient - machines: Machine[] - isLoading?: boolean - onSuccess: (sessionId: string) => void - onCancel: () => void -}) { - const { haptic } = usePlatform() - const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) - const { sessions } = useSessions(props.api) - const isFormDisabled = isPending || props.isLoading - const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() - - const [machineId, setMachineId] = useState(null) - const [directory, setDirectory] = useState('') - const [suppressSuggestions, setSuppressSuggestions] = useState(false) - const [isDirectoryFocused, setIsDirectoryFocused] = useState(false) - const [pathExistence, setPathExistence] = useState>({}) - const [agent, setAgent] = useState('claude') - const [yoloMode, setYoloMode] = useState(false) - const [sessionType, setSessionType] = useState('simple') - const [worktreeName, setWorktreeName] = useState('') - const [error, setError] = useState(null) - const worktreeInputRef = useRef(null) - - // Focus worktree input when switching to worktree mode - useEffect(() => { - if (sessionType === 'worktree') { - worktreeInputRef.current?.focus() - } - }, [sessionType]) - - // Initialize with last used machine or first available - useEffect(() => { - if (props.machines.length === 0) return - if (machineId && props.machines.find((m) => m.id === machineId)) return - - const lastUsed = getLastUsedMachineId() - const foundLast = lastUsed ? props.machines.find((m) => m.id === lastUsed) : null - - if (foundLast) { - setMachineId(foundLast.id) - const paths = getRecentPaths(foundLast.id) - if (paths[0]) setDirectory(paths[0]) - } else if (props.machines[0]) { - setMachineId(props.machines[0].id) - } - }, [props.machines, machineId, getLastUsedMachineId, getRecentPaths]) - - const selectedMachine = useMemo( - () => props.machines.find((m) => m.id === machineId) ?? null, - [props.machines, machineId] - ) - - const recentPaths = useMemo( - () => getRecentPaths(machineId), - [getRecentPaths, machineId] - ) - - const allPaths = useDirectorySuggestions(machineId, sessions, recentPaths) - - const pathsToCheck = useMemo( - () => Array.from(new Set(allPaths)).slice(0, 1000), - [allPaths] - ) - - useEffect(() => { - let cancelled = false - - if (!machineId || pathsToCheck.length === 0) { - setPathExistence({}) - return () => { cancelled = true } - } - - void props.api.checkMachinePathsExists(machineId, pathsToCheck) - .then((result) => { - if (cancelled) return - setPathExistence(result.exists ?? {}) - }) - .catch(() => { - if (cancelled) return - setPathExistence({}) - }) - - return () => { - cancelled = true - } - }, [machineId, pathsToCheck, props.api]) - - const verifiedPaths = useMemo( - () => allPaths.filter((path) => pathExistence[path]), - [allPaths, pathExistence] - ) - - const getSuggestions = useCallback(async (query: string): Promise => { - const lowered = query.toLowerCase() - return verifiedPaths - .filter((path) => path.toLowerCase().includes(lowered)) - .slice(0, 8) - .map((path) => ({ - key: path, - text: path, - label: path - })) - }, [verifiedPaths]) - - const activeQuery = (!isDirectoryFocused || suppressSuggestions) ? null : directory - - const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( - activeQuery, - getSuggestions, - { allowEmptyQuery: true, autoSelectFirst: false } - ) - - const handleMachineChange = useCallback((newMachineId: string) => { - setMachineId(newMachineId) - // Auto-fill most recent path for the new machine - const paths = getRecentPaths(newMachineId) - if (paths[0]) { - setDirectory(paths[0]) - } else { - setDirectory('') - } - }, [getRecentPaths]) - - const handlePathClick = useCallback((path: string) => { - setDirectory(path) - }, []) - - const handleSuggestionSelect = useCallback((index: number) => { - const suggestion = suggestions[index] - if (suggestion) { - setDirectory(suggestion.text) - clearSuggestions() - setSuppressSuggestions(true) - } - }, [suggestions, clearSuggestions]) - - const handleDirectoryChange = useCallback((value: string) => { - setSuppressSuggestions(false) - setDirectory(value) - }, []) - - const handleDirectoryFocus = useCallback(() => { - setSuppressSuggestions(false) - setIsDirectoryFocused(true) - }, []) - - const handleDirectoryBlur = useCallback(() => { - setIsDirectoryFocused(false) - }, []) - - const handleDirectoryKeyDown = useCallback((event: ReactKeyboardEvent) => { - if (suggestions.length === 0) return - - if (event.key === 'ArrowUp') { - event.preventDefault() - moveUp() - } - - if (event.key === 'ArrowDown') { - event.preventDefault() - moveDown() - } - - if (event.key === 'Enter' || event.key === 'Tab') { - if (selectedIndex >= 0) { - event.preventDefault() - handleSuggestionSelect(selectedIndex) - } - } - - if (event.key === 'Escape') { - clearSuggestions() - } - }, [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect]) - - async function handleCreate() { - if (!machineId || !directory.trim()) return - - setError(null) - try { - const result = await spawnSession({ - machineId, - directory: directory.trim(), - agent, - yolo: yoloMode, - sessionType, - worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined - }) - - if (result.type === 'success') { - haptic.notification('success') - // Save for next time - setLastUsedMachineId(machineId) - addRecentPath(machineId, directory.trim()) - props.onSuccess(result.sessionId) - return - } - - haptic.notification('error') - setError(result.message) - } catch (e) { - haptic.notification('error') - setError(e instanceof Error ? e.message : 'Failed to create session') - } - } - - const canCreate = machineId && directory.trim() && !isFormDisabled - - return ( -
- {/* Machine Selector */} -
- - -
- - {/* Directory Input */} -
- -
- handleDirectoryChange(event.target.value)} - onKeyDown={handleDirectoryKeyDown} - onFocus={handleDirectoryFocus} - onBlur={handleDirectoryBlur} - disabled={isFormDisabled} - className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50" - /> - {suggestions.length > 0 && ( -
- - - -
- )} -
- - {/* Recent Paths */} - {recentPaths.length > 0 && ( -
- Recent: -
- {recentPaths.map((path) => ( - - ))} -
-
- )} -
- - {/* Session Type */} -
- -
- {(['simple', 'worktree'] as const).map((type) => ( -
- {type === 'worktree' ? ( -
- setSessionType('worktree')} - disabled={isFormDisabled} - className="accent-[var(--app-link)]" - /> -
-
- {sessionType === 'worktree' ? ( - setWorktreeName(e.target.value)} - disabled={isFormDisabled} - className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-60" - /> - ) : ( - <> - - - Create a new git worktree next to the repo - - - )} -
-
-
- ) : ( - - )} -
- ))} -
-
- - {/* Agent Selector */} -
- -
- {(['claude', 'codex', 'gemini'] as const).map((agentType) => ( - - ))} -
-
- - {/* YOLO Mode */} -
- -
-
- - Bypass approvals and sandbox - - - Uses dangerous agent flags when spawning. - -
- -
-
- - {/* Error Message */} - {(error ?? spawnError) ? ( -
- {error ?? spawnError} -
- ) : null} - - {/* Action Buttons */} -
- - -
-
- ) -} diff --git a/web/src/components/NewSession/ActionButtons.tsx b/web/src/components/NewSession/ActionButtons.tsx new file mode 100644 index 00000000..c1b44b54 --- /dev/null +++ b/web/src/components/NewSession/ActionButtons.tsx @@ -0,0 +1,37 @@ +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/Spinner' + +export function ActionButtons(props: { + isPending: boolean + canCreate: boolean + isDisabled: boolean + onCancel: () => void + onCreate: () => void +}) { + return ( +
+ + +
+ ) +} diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx new file mode 100644 index 00000000..d53a2758 --- /dev/null +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -0,0 +1,34 @@ +import type { AgentType } from './types' + +export function AgentSelector(props: { + agent: AgentType + isDisabled: boolean + onAgentChange: (value: AgentType) => void +}) { + return ( +
+ +
+ {(['claude', 'codex', 'gemini'] as const).map((agentType) => ( + + ))} +
+
+ ) +} diff --git a/web/src/components/NewSession/DirectorySection.tsx b/web/src/components/NewSession/DirectorySection.tsx new file mode 100644 index 00000000..f431b062 --- /dev/null +++ b/web/src/components/NewSession/DirectorySection.tsx @@ -0,0 +1,70 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import type { Suggestion } from '@/hooks/useActiveSuggestions' +import { Autocomplete } from '@/components/ChatInput/Autocomplete' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' + +export function DirectorySection(props: { + directory: string + suggestions: readonly Suggestion[] + selectedIndex: number + isDisabled: boolean + recentPaths: string[] + onDirectoryChange: (value: string) => void + onDirectoryFocus: () => void + onDirectoryBlur: () => void + onDirectoryKeyDown: (event: ReactKeyboardEvent) => void + onSuggestionSelect: (index: number) => void + onPathClick: (path: string) => void +}) { + return ( +
+ +
+ props.onDirectoryChange(event.target.value)} + onKeyDown={props.onDirectoryKeyDown} + onFocus={props.onDirectoryFocus} + onBlur={props.onDirectoryBlur} + disabled={props.isDisabled} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50" + /> + {props.suggestions.length > 0 && ( +
+ + + +
+ )} +
+ + {props.recentPaths.length > 0 && ( +
+ Recent: +
+ {props.recentPaths.map((path) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/web/src/components/NewSession/MachineSelector.tsx b/web/src/components/NewSession/MachineSelector.tsx new file mode 100644 index 00000000..5732efb7 --- /dev/null +++ b/web/src/components/NewSession/MachineSelector.tsx @@ -0,0 +1,42 @@ +import type { Machine } from '@/types/api' + +function getMachineTitle(machine: Machine): string { + if (machine.metadata?.displayName) return machine.metadata.displayName + if (machine.metadata?.host) return machine.metadata.host + return machine.id.slice(0, 8) +} + +export function MachineSelector(props: { + machines: Machine[] + machineId: string | null + isLoading?: boolean + isDisabled: boolean + onChange: (machineId: string) => void +}) { + return ( +
+ + +
+ ) +} diff --git a/web/src/components/NewSession/SessionTypeSelector.tsx b/web/src/components/NewSession/SessionTypeSelector.tsx new file mode 100644 index 00000000..78d1613e --- /dev/null +++ b/web/src/components/NewSession/SessionTypeSelector.tsx @@ -0,0 +1,83 @@ +import type { RefObject } from 'react' +import type { SessionType } from './types' + +export function SessionTypeSelector(props: { + sessionType: SessionType + worktreeName: string + worktreeInputRef: RefObject + isDisabled: boolean + onSessionTypeChange: (value: SessionType) => void + onWorktreeNameChange: (value: string) => void +}) { + return ( +
+ +
+ {(['simple', 'worktree'] as const).map((type) => ( +
+ {type === 'worktree' ? ( +
+ props.onSessionTypeChange('worktree')} + disabled={props.isDisabled} + className="accent-[var(--app-link)]" + /> +
+
+ {props.sessionType === 'worktree' ? ( + props.onWorktreeNameChange(e.target.value)} + disabled={props.isDisabled} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-60" + /> + ) : ( + <> + + + Create a new git worktree next to the repo + + + )} +
+
+
+ ) : ( + + )} +
+ ))} +
+
+ ) +} diff --git a/web/src/components/NewSession/YoloToggle.tsx b/web/src/components/NewSession/YoloToggle.tsx new file mode 100644 index 00000000..cdbaac3f --- /dev/null +++ b/web/src/components/NewSession/YoloToggle.tsx @@ -0,0 +1,34 @@ +export function YoloToggle(props: { + yoloMode: boolean + isDisabled: boolean + onToggle: (value: boolean) => void +}) { + return ( +
+ +
+
+ + Bypass approvals and sandbox + + + Uses dangerous agent flags when spawning. + +
+ +
+
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx new file mode 100644 index 00000000..907e4ee5 --- /dev/null +++ b/web/src/components/NewSession/index.tsx @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import type { ApiClient } from '@/api/client' +import type { Machine } from '@/types/api' +import { usePlatform } from '@/hooks/usePlatform' +import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' +import { useSessions } from '@/hooks/queries/useSessions' +import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' +import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' +import { useRecentPaths } from '@/hooks/useRecentPaths' +import type { AgentType, SessionType } from './types' +import { ActionButtons } from './ActionButtons' +import { AgentSelector } from './AgentSelector' +import { DirectorySection } from './DirectorySection' +import { MachineSelector } from './MachineSelector' +import { SessionTypeSelector } from './SessionTypeSelector' +import { YoloToggle } from './YoloToggle' + +export function NewSession(props: { + api: ApiClient + machines: Machine[] + isLoading?: boolean + onSuccess: (sessionId: string) => void + onCancel: () => void +}) { + const { haptic } = usePlatform() + const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) + const { sessions } = useSessions(props.api) + const isFormDisabled = Boolean(isPending || props.isLoading) + const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() + + const [machineId, setMachineId] = useState(null) + const [directory, setDirectory] = useState('') + const [suppressSuggestions, setSuppressSuggestions] = useState(false) + const [isDirectoryFocused, setIsDirectoryFocused] = useState(false) + const [pathExistence, setPathExistence] = useState>({}) + const [agent, setAgent] = useState('claude') + const [yoloMode, setYoloMode] = useState(false) + const [sessionType, setSessionType] = useState('simple') + const [worktreeName, setWorktreeName] = useState('') + const [error, setError] = useState(null) + const worktreeInputRef = useRef(null) + + useEffect(() => { + if (sessionType === 'worktree') { + worktreeInputRef.current?.focus() + } + }, [sessionType]) + + useEffect(() => { + if (props.machines.length === 0) return + if (machineId && props.machines.find((m) => m.id === machineId)) return + + const lastUsed = getLastUsedMachineId() + const foundLast = lastUsed ? props.machines.find((m) => m.id === lastUsed) : null + + if (foundLast) { + setMachineId(foundLast.id) + const paths = getRecentPaths(foundLast.id) + if (paths[0]) setDirectory(paths[0]) + } else if (props.machines[0]) { + setMachineId(props.machines[0].id) + } + }, [props.machines, machineId, getLastUsedMachineId, getRecentPaths]) + + const recentPaths = useMemo( + () => getRecentPaths(machineId), + [getRecentPaths, machineId] + ) + + const allPaths = useDirectorySuggestions(machineId, sessions, recentPaths) + + const pathsToCheck = useMemo( + () => Array.from(new Set(allPaths)).slice(0, 1000), + [allPaths] + ) + + useEffect(() => { + let cancelled = false + + if (!machineId || pathsToCheck.length === 0) { + setPathExistence({}) + return () => { cancelled = true } + } + + void props.api.checkMachinePathsExists(machineId, pathsToCheck) + .then((result) => { + if (cancelled) return + setPathExistence(result.exists ?? {}) + }) + .catch(() => { + if (cancelled) return + setPathExistence({}) + }) + + return () => { + cancelled = true + } + }, [machineId, pathsToCheck, props.api]) + + const verifiedPaths = useMemo( + () => allPaths.filter((path) => pathExistence[path]), + [allPaths, pathExistence] + ) + + const getSuggestions = useCallback(async (query: string): Promise => { + const lowered = query.toLowerCase() + return verifiedPaths + .filter((path) => path.toLowerCase().includes(lowered)) + .slice(0, 8) + .map((path) => ({ + key: path, + text: path, + label: path + })) + }, [verifiedPaths]) + + const activeQuery = (!isDirectoryFocused || suppressSuggestions) ? null : directory + + const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( + activeQuery, + getSuggestions, + { allowEmptyQuery: true, autoSelectFirst: false } + ) + + const handleMachineChange = useCallback((newMachineId: string) => { + setMachineId(newMachineId) + const paths = getRecentPaths(newMachineId) + if (paths[0]) { + setDirectory(paths[0]) + } else { + setDirectory('') + } + }, [getRecentPaths]) + + const handlePathClick = useCallback((path: string) => { + setDirectory(path) + }, []) + + const handleSuggestionSelect = useCallback((index: number) => { + const suggestion = suggestions[index] + if (suggestion) { + setDirectory(suggestion.text) + clearSuggestions() + setSuppressSuggestions(true) + } + }, [suggestions, clearSuggestions]) + + const handleDirectoryChange = useCallback((value: string) => { + setSuppressSuggestions(false) + setDirectory(value) + }, []) + + const handleDirectoryFocus = useCallback(() => { + setSuppressSuggestions(false) + setIsDirectoryFocused(true) + }, []) + + const handleDirectoryBlur = useCallback(() => { + setIsDirectoryFocused(false) + }, []) + + const handleDirectoryKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (suggestions.length === 0) return + + if (event.key === 'ArrowUp') { + event.preventDefault() + moveUp() + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + moveDown() + } + + if (event.key === 'Enter' || event.key === 'Tab') { + if (selectedIndex >= 0) { + event.preventDefault() + handleSuggestionSelect(selectedIndex) + } + } + + if (event.key === 'Escape') { + clearSuggestions() + } + }, [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect]) + + async function handleCreate() { + if (!machineId || !directory.trim()) return + + setError(null) + try { + const result = await spawnSession({ + machineId, + directory: directory.trim(), + agent, + yolo: yoloMode, + sessionType, + worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined + }) + + if (result.type === 'success') { + haptic.notification('success') + setLastUsedMachineId(machineId) + addRecentPath(machineId, directory.trim()) + props.onSuccess(result.sessionId) + return + } + + haptic.notification('error') + setError(result.message) + } catch (e) { + haptic.notification('error') + setError(e instanceof Error ? e.message : 'Failed to create session') + } + } + + const canCreate = Boolean(machineId && directory.trim() && !isFormDisabled) + + return ( +
+ + + + + + + {(error ?? spawnError) ? ( +
+ {error ?? spawnError} +
+ ) : null} + + +
+ ) +} diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts new file mode 100644 index 00000000..97dbafbe --- /dev/null +++ b/web/src/components/NewSession/types.ts @@ -0,0 +1,2 @@ +export type AgentType = 'claude' | 'codex' | 'gemini' +export type SessionType = 'simple' | 'worktree'