diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 9151754fad..fb81e2ed83 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -362,8 +362,8 @@ When `VERCEL_TARGET_ENV` is absent in local development or a script process, tra - `KILO_BIN_PATH` - Path or name of the `kilo` CLI binary; used by `services/cloud-agent-next/scripts/update-default-slash-commands.mjs`. [SERVER] - `WORKSPACE_PATH` - Filesystem path of the agent workspace. [SERVER] - `SESSION_ID` - Reserved session identifier for the `cloud-agent-next` runtime; reserved in `RESERVED_ENV_VARS`. [SERVER] -- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. Does not enable new worktree creation by itself; that also requires `WORKTREE_CREATION_ENABLED_IDS` enrollment. [SERVER] -- `WORKTREE_CREATION_ENABLED_IDS` - Comma-separated user or org IDs allowed to create new worktrees, or `*` for all, including personal accounts. Defaults to empty/off and also requires enrollment in `CONTROL_PLANE_IDS`. Disabling it does not block existing worktrees or sibling chats in them. [SERVER] +- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. Production defaults to empty. Wrangler `dev` defaults to `*`. Does not enable new worktree creation by itself; that also requires `WORKTREE_CREATION_ENABLED_IDS` enrollment. [SERVER] +- `WORKTREE_CREATION_ENABLED_IDS` - Comma-separated user or org IDs allowed to create new worktrees, or `*` for all, including personal accounts. Production defaults to empty/off. Wrangler `dev` defaults to `*`. Also requires enrollment in `CONTROL_PLANE_IDS`. Disabling it does not block existing worktrees or sibling chats in them. [SERVER] - `VERCEL_SANDBOX_ORG_IDS` - Comma-separated org IDs routed to Vercel sandboxes. Empty is off. `*` includes personal accounts. [SERVER] - `HOME` - Reserved in `RESERVED_ENV_VARS` for cloud-agent-next session home management. [SYSTEM] diff --git a/apps/web/src/components/cloud-agent-next/ApplyPatchToolCard.tsx b/apps/web/src/components/cloud-agent-next/ApplyPatchToolCard.tsx new file mode 100644 index 0000000000..cfc81a8c9f --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/ApplyPatchToolCard.tsx @@ -0,0 +1,115 @@ +import { FileDiff } from 'lucide-react'; +import { ToolCardShell } from './ToolCardShell'; +import { ToolDiff, ToolDiffStats, ToolFilePath } from './ToolDiff'; +import { ToolCodeBlock } from './ToolOutput'; +import { + getUnifiedPatch, + MAX_TOOL_DIFF_CHARACTERS, + readApplyPatchFiles, + sumFileChanges, +} from './toolDiffUtils'; +import type { ToolPart } from './types'; + +type ApplyPatchToolCardProps = { + toolPart: ToolPart; +}; + +const changeLabels = { + add: 'Added', + update: 'Updated', + delete: 'Deleted', + move: 'Moved', +}; + +export function ApplyPatchToolCard({ toolPart }: ApplyPatchToolCardProps) { + const state = toolPart.state; + const files = readApplyPatchFiles(state.status === 'pending' ? undefined : state.metadata); + const single = files.length === 1 ? files[0] : undefined; + const patchText = typeof state.input.patchText === 'string' ? state.input.patchText : undefined; + const error = state.status === 'error' ? state.error : undefined; + + return ( + + ) : files.length > 0 ? ( + `${files.length} files` + ) : undefined + } + badge={} + status={state.status} + > + {files.map((file, index) => { + const filePath = file.relativePath ?? file.movePath ?? file.filePath; + return ( +
+
+ + {file.type ? changeLabels[file.type] : 'Changed'} + + {filePath} + +
+ {file.type === 'move' && ( +
+
+ From: {file.filePath ?? 'Unknown source'} +
+
+ To:{' '} + + {file.movePath ?? file.relativePath ?? 'Unknown destination'} + +
+
+ )} + +
+ ); + })} + {files.length === 0 && ( +
+
+ File summaries and diff preview unavailable: no usable file metadata was provided. +
+ {patchText !== undefined && patchText.length > 0 && ( +
+
Patch input (not an applied diff)
+
+                {patchText.slice(0, MAX_TOOL_DIFF_CHARACTERS)}
+              
+ {patchText.length > MAX_TOOL_DIFF_CHARACTERS && ( +
Patch input truncated.
+ )} +
+ )} + {state.status === 'completed' && state.output.trim() && ( + + )} +
+ )} + {error && ( +
+          {error}
+        
+ )} + {state.status === 'running' && ( +
Applying patch…
+ )} + {state.status === 'pending' && ( +
Waiting to apply patch…
+ )} +
+ ); +} diff --git a/apps/web/src/components/cloud-agent-next/BackgroundProcessToolCard.tsx b/apps/web/src/components/cloud-agent-next/BackgroundProcessToolCard.tsx new file mode 100644 index 0000000000..f7025a823c --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/BackgroundProcessToolCard.tsx @@ -0,0 +1,158 @@ +import { Fragment } from 'react'; +import { Terminal } from 'lucide-react'; +import * as z from 'zod'; +import { ToolCardShell } from './ToolCardShell'; +import { ToolCodeBlock } from './ToolOutput'; +import { normalizeTerminalOutput } from './normalize-terminal-output'; +import type { ToolPart } from './types'; + +const actionTitles = new Map([ + ['start', 'Start background process'], + ['list', 'List background processes'], + ['status', 'Check background process'], + ['logs', 'View background logs'], + ['stop', 'Stop background process'], + ['restart', 'Restart background process'], +]); +const structuredActions = new Set(['start', 'status', 'stop', 'restart']); +const structuredKeys = new Set(['id', 'status', 'pid', 'cwd', 'command', 'last_output', 'output']); +const resultSchema = z.record(z.string(), z.unknown()); +const readinessSchema = z.object({ + port: z.number().int().positive().optional(), + pattern: z.string().optional(), + timeout: z.number().positive().optional(), +}); + +function text(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'boolean') return String(value); + return undefined; +} + +function structuredOutput(raw: string, enabled: boolean) { + const fields = new Map(); + if (!enabled) return { fields, output: raw }; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + const result = resultSchema.safeParse(parsed); + if (result.success) { + const rest = Object.fromEntries( + Object.entries(result.data).filter(([key, value]) => { + const valueText = text(value); + if (!structuredKeys.has(key) || valueText === undefined) return true; + fields.set(key, valueText); + return false; + }) + ); + return { + fields, + output: + fields.size === 0 ? raw : Object.keys(rest).length ? JSON.stringify(rest, null, 2) : '', + }; + } + + const rest: string[] = []; + for (const line of raw.split('\n')) { + const match = /^([a-z_]+):\s*(.*)$/.exec(line); + const key = match?.[1]; + const value = match?.[2].trim(); + if (key && structuredKeys.has(key) && value && !fields.has(key)) { + fields.set(key, value); + } else { + rest.push(line); + } + } + return { fields, output: rest.join('\n').trimEnd() }; +} + +export function BackgroundProcessToolCard({ toolPart }: { toolPart: ToolPart }) { + const state = toolPart.state; + const input = state.input; + const metadata = state.status === 'pending' ? undefined : state.metadata; + const action = text(input.action) ?? 'status'; + const rawOutput = state.status === 'completed' ? normalizeTerminalOutput(state.output) : ''; + const data = structuredOutput( + rawOutput, + state.status === 'completed' && structuredActions.has(action) + ); + const id = data.fields.get('id') ?? text(metadata?.processID) ?? text(input.id); + const status = data.fields.get('status') ?? text(metadata?.status); + const command = text(input.command) ?? data.fields.get('command'); + const description = text(input.description); + const cwd = data.fields.get('cwd') ?? text(input.cwd) ?? text(input.workdir); + const parsedReadiness = readinessSchema.safeParse(input.ready); + const readiness = parsedReadiness.success ? parsedReadiness.data : undefined; + const rows: [string, string | undefined][] = [ + ['Description', description], + ['Process id', id], + ['Status', status], + ['PID', data.fields.get('pid')], + ['Cwd', cwd], + ['Readiness port', text(readiness?.port)], + ['Readiness pattern', text(readiness?.pattern)], + ['Readiness timeout', readiness?.timeout !== undefined ? `${readiness.timeout} ms` : undefined], + ]; + const output = normalizeTerminalOutput( + [data.fields.get('output'), data.output].filter(Boolean).join('\n\n') + ); + const lastOutput = normalizeTerminalOutput(data.fields.get('last_output') ?? ''); + const count = metadata?.count; + const subtitle = + action === 'list' && typeof count === 'number' && Number.isInteger(count) && count >= 0 + ? `${count} ${count === 1 ? 'process' : 'processes'}` + : (command ?? description ?? id); + + return ( + +
+ {command && } + {rows.some(([, value]) => value !== undefined) && ( +
+ {rows.map(([label, value]) => + value !== undefined ? ( + +
{label}
+
{value}
+
+ ) : null + )} +
+ )} + {lastOutput && } + {output.trim() ? : null} + {state.status === 'completed' && !rawOutput.trim() && ( +
No output.
+ )} + {state.status === 'error' && ( + + )} + {state.status === 'running' && ( +
Waiting for process result...
+ )} + {state.status === 'pending' && ( +
Waiting...
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/cloud-agent-next/BashToolCard.tsx b/apps/web/src/components/cloud-agent-next/BashToolCard.tsx index 8ae6ce7c7b..ca5950ec3c 100644 --- a/apps/web/src/components/cloud-agent-next/BashToolCard.tsx +++ b/apps/web/src/components/cloud-agent-next/BashToolCard.tsx @@ -1,93 +1,83 @@ import { Terminal } from 'lucide-react'; import type { ToolPart } from './types'; import { ToolCardShell } from './ToolCardShell'; +import { ToolCodeBlock } from './ToolOutput'; +import { normalizeTerminalOutput } from './normalize-terminal-output'; type BashToolCardProps = { toolPart: ToolPart; }; -type BashInput = { - command: string; - description?: string; - workdir?: string; - timeout?: number; -}; - -// Replace agent workspace paths like /workspace///sessions/ -// with "." so truncated command previews show the actual command content. -const WORKSPACE_PATH_PATTERN = /\/workspace\/[^/\s]+\/[^/\s]+\/sessions\/[^/\s]+/g; +const WORKSPACE_PATH_PATTERN = /\/workspace\/(?:[^/\s]+\/)?[^/\s]+\/sessions\/[^/\s]+/g; -function normalizeCommandForDisplay(command: string): string { - return command.replace(WORKSPACE_PATH_PATTERN, '.'); +function getCommandPreview(command: string): string { + const normalized = command.replace(WORKSPACE_PATH_PATTERN, '.'); + const firstLine = normalized.split('\n')[0] || normalized; + return firstLine.length > 60 ? firstLine.slice(0, 57) + '...' : firstLine; } -function getCommandPreview(command: string): string { - // Get first line or first 60 chars, whichever is shorter - const firstLine = - normalizeCommandForDisplay(command).split('\n')[0] || normalizeCommandForDisplay(command); - if (firstLine.length > 60) { - return firstLine.slice(0, 57) + '...'; - } - return firstLine; +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; } export function BashToolCard({ toolPart }: BashToolCardProps) { const state = toolPart.state; - const input = state.input as BashInput; - const output = state.status === 'completed' ? state.output : undefined; - const error = state.status === 'error' ? state.error : undefined; - const commandPreview = getCommandPreview(input.command); + const input = state.input; + const metadata = state.status === 'pending' ? undefined : state.metadata; + const command = text(input.command) ?? text(metadata?.command) ?? ''; + const description = text(input.description) ?? text(metadata?.description); + const cwd = text(input.workdir); + const rawOutput = + state.status === 'completed' + ? state.output + : state.status === 'running' && typeof metadata?.output === 'string' + ? metadata.output + : ''; + const output = normalizeTerminalOutput(rawOutput); return ( - - {/* Description if provided */} - {input.description && ( -
{input.description}
+ + {command && ( + } + /> )} - - {/* Full command if different from preview */} - {input.command !== commandPreview && ( -
-
Command:
-
-            {input.command}
-          
+ {cwd && ( +
+ cwd: {cwd}
)} - - {/* Working directory */} - {input.workdir && ( -
cwd: {input.workdir}
+ {output.trim() ? ( + + ) : null} + {state.status === 'completed' && !output.trim() && ( +
Command completed with no output.
)} - - {/* Output */} - {output != null && output !== '' && ( -
-
Output:
-
-            {output}
-          
-
- )} - - {/* Error */} - {error && ( -
-
Error:
-
-            {error}
-          
-
+ {state.status === 'error' && ( + )} - - {/* Running state */} - {state.status === 'running' && ( -
Running command...
+ {state.status === 'running' && !output.trim() && ( +
Waiting for output...
)} - - {/* Pending state */} {state.status === 'pending' && ( -
Waiting to execute...
+
Waiting to execute...
)} ); diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.tsx b/apps/web/src/components/cloud-agent-next/ChatInput.tsx index 754955141e..4e7fe1769d 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatInput.tsx @@ -415,7 +415,7 @@ export function ChatInput({ showToolbar && onModeChange && onModelChange && (modelOptions.length > 0 || pinnedModelOption); return ( -
+
total + message.parts.filter(isToolPart).length, + 0 + ); const canOpenDrawer = Boolean(sessionId && onOpenChildSession); const inlineRenderPart = sessionId && !canOpenDrawer ? renderPart : undefined; const canExpandInline = Boolean(inlineRenderPart); @@ -74,80 +83,74 @@ export function ChildSessionSection({ } }; - const borderColor = - taskStatus === 'error' - ? 'border-red-500/40' - : taskStatus === 'completed' - ? 'border-green-500/40' - : 'border-blue-500/40'; - const rowContent = ( <> - {canOpenDrawer ? ( - - ) : canExpandInline ? ( - isExpanded ? ( - - ) : ( - - ) - ) : ( - - )} - {isRunning ? ( - + ) : ( - + )} - - - {description || 'Subtask'} - {currentTool && ( - - {currentTool.tool} - {currentTool.context && {currentTool.context}} + + + + {description || 'Subagent task'} + + {agentLabel && ( + + + {agentLabel} + + + )} + + {isRunning && ( + + {progress} )} - - {taskStatus && ( - - {taskStatus} - - )} - - {agent && ( - - {agent} - + {(statusLabel || toolCount > 0) && ( + + {statusLabel && ( + + {statusLabel} + + )} + {statusLabel && toolCount > 0 && } + {toolCount > 0 && ( + + {toolCount} {toolCount === 1 ? 'tool call' : 'tool calls'} + + )} + )} + {isInteractive && + (canExpandInline && isExpanded ? ( + + ) : ( + + ))} ); return ( -
+
{isInteractive ? ( ) : ( -
- {rowContent} -
+
{rowContent}
)} {isExpanded && inlineRenderPart && ( @@ -274,14 +277,7 @@ export function getTaskToolSessionId(toolPart: ToolPart): KiloSessionId | undefi return undefined; } -/** - * Find the currently running tool from child session messages. - * Looks through all assistant messages to find a tool part with status 'running' or 'pending'. - * Returns the tool name and optional context (e.g., filename for read/edit tools). - */ -export function getCurrentRunningTool( - childMessages: StoredMessage[] -): { tool: string; context?: string } | undefined { +function getLatestToolActivity(childMessages: StoredMessage[]): string | undefined { for (let i = childMessages.length - 1; i >= 0; i--) { const msg = childMessages[i]; if (msg.info.role !== 'assistant') continue; @@ -290,38 +286,31 @@ export function getCurrentRunningTool( const part = msg.parts[j]; if (!isToolPart(part)) continue; - const status = part.state.status; - if (status === 'running' || status === 'pending') { - const tool = part.tool; - let context: string | undefined; - - const input = part.state.input; - if (tool === 'read' || tool === 'edit' || tool === 'write') { - const filePath = getStringProperty(input, 'filePath'); - if (filePath) { - context = filePath.split('/').pop(); - } - } else if (tool === 'bash') { - const command = getStringProperty(input, 'command'); - if (command) { - const firstWord = command.split(/\s+/)[0]; - context = firstWord.length > 20 ? firstWord.slice(0, 20) + '...' : firstWord; - } - } else if (tool === 'glob' || tool === 'grep') { - const pattern = getStringProperty(input, 'pattern'); - if (pattern) { - context = pattern.length > 25 ? pattern.slice(0, 25) + '...' : pattern; - } - } else if (tool === 'task') { - const taskDescription = getStringProperty(input, 'description'); - if (taskDescription) { - context = - taskDescription.length > 30 ? taskDescription.slice(0, 30) + '...' : taskDescription; - } + const tool = part.tool; + let context: string | undefined; + const input = part.state.input; + if (tool === 'read' || tool === 'edit' || tool === 'write') { + const filePath = getStringProperty(input, 'filePath'); + if (filePath) context = filePath.split('/').pop(); + } else if (tool === 'bash') { + const command = getStringProperty(input, 'command'); + if (command) { + const firstWord = command.split(/\s+/)[0]; + context = firstWord.length > 20 ? firstWord.slice(0, 20) + '...' : firstWord; + } + } else if (tool === 'glob' || tool === 'grep') { + const pattern = getStringProperty(input, 'pattern'); + if (pattern) context = pattern.length > 25 ? pattern.slice(0, 25) + '...' : pattern; + } else if (tool === 'task') { + const taskDescription = getStringProperty(input, 'description'); + if (taskDescription) { + context = + taskDescription.length > 30 ? taskDescription.slice(0, 30) + '...' : taskDescription; } - - return { tool, context }; } + + const label = tool === 'bash' ? 'Shell' : tool.charAt(0).toUpperCase() + tool.slice(1); + return `${label}${context ? ` ${context}` : ''}${part.state.status === 'error' ? ' (failed)' : ''}`; } } return undefined; diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 320a077beb..00abae8678 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { useSearchParams } from 'next/navigation'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -29,12 +29,10 @@ import { getSessionTotalCostUsd, isRenderableSessionCost, } from './session-cost-breakdown'; -import { MessageErrorBoundary } from './MessageErrorBoundary'; -import { MessageBubble } from './MessageBubble'; +import { ConversationMessages } from './ConversationMessages'; import { ChildSessionDrawer } from './ChildSessionDrawer'; -import type { ChildSessionDrawerEntry, OpenChildSession } from './ChildSessionSection'; +import type { ChildSessionDrawerEntry } from './ChildSessionSection'; import { SessionStatusIndicator } from './SessionStatusIndicator'; -import { PreparationRow } from './PreparationRow'; import { isNoOpCompletedPreparationAttempt } from './preparation-summary'; import { PreparationDrawer } from './PreparationDrawer'; import { WorkingIndicator } from './WorkingIndicator'; @@ -64,7 +62,6 @@ import { selectWorkspaceTab, terminalTabId, } from './terminal-tabs'; -import { isMessageStreaming } from './types'; import { createRemoteModelOverride, useSessionModels, @@ -80,123 +77,10 @@ import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; import { SetPageTitle } from '@/components/SetPageTitle'; import { formatShortModelDisplayName } from '@/lib/format-model-name'; import type { AgentMode } from './types'; -import type { - MessageDeliveryState, - PreparationAttempt, - StoredMessage, -} from '@kilocode/cloud-agent-sdk'; +import type { PreparationAttempt } from '@kilocode/cloud-agent-sdk'; import type { WorkspaceTabId } from './terminal-tabs'; import type { TerminalStatus } from './useCloudAgentTerminal'; -// --------------------------------------------------------------------------- -// Static messages — memoized, never re-renders during streaming -// --------------------------------------------------------------------------- -const StaticMessages = memo( - ({ - messages, - pendingMessages, - preparationByMessageId, - getChildMessages, - onOpenChildSession, - onOpenPreparationDetails, - }: { - messages: StoredMessage[]; - pendingMessages: ReadonlyMap; - preparationByMessageId: ReadonlyMap; - getChildMessages?: (sessionId: string) => StoredMessage[]; - onOpenChildSession?: OpenChildSession; - onOpenPreparationDetails: (attemptId: string) => void; - }) => ( - <> - {messages.map(msg => ( - - - {preparationByMessageId.get(msg.info.id)?.map(attempt => ( - - ))} - - ))} - - ) -); -StaticMessages.displayName = 'StaticMessages'; - -// --------------------------------------------------------------------------- -// Dynamic messages — re-renders as streaming progresses while chat is visible -// --------------------------------------------------------------------------- -type DynamicMessagesProps = { - active: boolean; - isStreaming: boolean; - messages: StoredMessage[]; - pendingMessages: ReadonlyMap; - preparationByMessageId: ReadonlyMap; - getChildMessages?: (sessionId: string) => StoredMessage[]; - onOpenChildSession?: OpenChildSession; - onOpenPreparationDetails: (attemptId: string) => void; -}; - -const DynamicMessages = memo( - function DynamicMessages({ - isStreaming, - messages, - pendingMessages, - preparationByMessageId, - getChildMessages, - onOpenChildSession, - onOpenPreparationDetails, - }: DynamicMessagesProps) { - return ( - <> - {messages.map(msg => { - const streaming = isStreaming && isMessageStreaming(msg); - return ( - - - {preparationByMessageId.get(msg.info.id)?.map(attempt => ( - - ))} - - ); - })} - - ); - }, - (previous, next) => { - if (!previous.active && !next.active) return true; - - return ( - previous.active === next.active && - previous.isStreaming === next.isStreaming && - previous.messages === next.messages && - previous.pendingMessages === next.pendingMessages && - previous.preparationByMessageId === next.preparationByMessageId && - previous.getChildMessages === next.getChildMessages && - previous.onOpenChildSession === next.onOpenChildSession && - previous.onOpenPreparationDetails === next.onOpenPreparationDetails - ); - } -); -DynamicMessages.displayName = 'DynamicMessages'; - // --------------------------------------------------------------------------- // CloudChatPage // --------------------------------------------------------------------------- @@ -1194,7 +1078,7 @@ export default function CloudChatPage({