Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/browser/components/AppLoader.auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@ void mock.module("@/browser/contexts/API", () => ({
}));

void mock.module("@/browser/components/AuthTokenModal", () => ({
// Note: Module mocks leak between bun test files.
// Export all commonly-used symbols to avoid cross-test import errors.
AuthTokenModal: (props: { error?: string | null }) => (
<div data-testid="AuthTokenModalMock">{props.error ?? "no-error"}</div>
),
getStoredAuthToken: () => null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setStoredAuthToken: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
clearStoredAuthToken: () => {},
}));

import { AppLoader } from "./AppLoader";
Expand Down
100 changes: 100 additions & 0 deletions src/browser/components/Messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,73 @@ function isTaskListTool(toolName: string, args: unknown): args is TaskListToolAr
return TOOL_DEFINITIONS.task_list.schema.safeParse(args).success;
}

function isTaskBashArgs(args: TaskToolArgs): args is TaskToolArgs & {
kind: "bash";
script: string;
display_name: string;
timeout_secs: number;
} {
return "kind" in args && args.kind === "bash";
}

function taskBashResultToBashToolResult(
result: TaskToolSuccessResult | undefined
): BashToolResult | undefined {
if (!result) return undefined;

if (result.status !== "completed") {
const taskId = result.taskId;
if (typeof taskId === "string" && taskId.startsWith("bash:")) {
const processId = taskId.slice("bash:".length).trim() || taskId;
return {
success: true,
output: "",
exitCode: 0,
wall_duration_ms: 0,
backgroundProcessId: processId,
};
}
return undefined;
}

const report = result.reportMarkdown ?? "";

const exitCodeMatch = /exitCode:\s*(-?\d+)/.exec(report);
const parsedExitCode = exitCodeMatch ? Number(exitCodeMatch[1]) : undefined;
const exitCode = result.exitCode ?? (Number.isFinite(parsedExitCode) ? parsedExitCode! : 0);

const wallDurationMatch = /wall_duration_ms:\s*(\d+)/.exec(report);
const parsedWallDuration = wallDurationMatch ? Number(wallDurationMatch[1]) : undefined;
const wall_duration_ms = Number.isFinite(parsedWallDuration) ? parsedWallDuration! : 0;

const textBlockMatch = /```text\n([\s\S]*?)\n```/.exec(report);
const output = textBlockMatch ? textBlockMatch[1] : "";

const errorLineMatch = /^error:\s*(.*)$/m.exec(report);
const error = errorLineMatch?.[1] ?? `Command exited with code ${exitCode}`;

if (exitCode === 0) {
return {
success: true,
output,
exitCode: 0,
wall_duration_ms,
note: result.note,
truncated: result.truncated,
};
}

return {
success: false,
output: output.length > 0 ? output : undefined,
exitCode,
error,
wall_duration_ms,
note: result.note,
truncated: result.truncated,
};
}

function isTaskTerminateTool(toolName: string, args: unknown): args is TaskTerminateToolArgs {
if (toolName !== "task_terminate") return false;
return TOOL_DEFINITIONS.task_terminate.schema.safeParse(args).success;
Expand Down Expand Up @@ -389,6 +456,39 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
}

if (isTaskTool(message.toolName, message.args)) {
if (isTaskBashArgs(message.args)) {
const canSendToBackground = foregroundBashToolCallIds?.has(message.toolCallId) ?? false;
const toolCallId = message.toolCallId;

const bashArgs: BashToolArgs = {
script: message.args.script,
timeout_secs: message.args.timeout_secs,
run_in_background: message.args.run_in_background,
display_name: message.args.display_name,
};

const bashResult = taskBashResultToBashToolResult(
message.result as TaskToolSuccessResult | undefined
);

return (
<div className={className}>
<BashToolCall
workspaceId={workspaceId}
toolCallId={message.toolCallId}
args={bashArgs}
result={bashResult}
status={message.status}
startedAt={message.timestamp}
canSendToBackground={canSendToBackground}
onSendToBackground={
onSendBashToBackground ? () => onSendBashToBackground(toolCallId) : undefined
}
/>
</div>
);
}

return (
<div className={className}>
<TaskToolCall
Expand Down
93 changes: 74 additions & 19 deletions src/browser/components/tools/TaskToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ const TaskId: React.FC<{ id: string; className?: string }> = ({ id, className })
// TASK TOOL CALL (spawn sub-agent)
// ═══════════════════════════════════════════════════════════════════════════════

function isBashTaskArgs(args: TaskToolArgs): args is TaskToolArgs & {
kind: "bash";
script: string;
display_name: string;
timeout_secs: number;
} {
return args.kind === "bash";
}
interface TaskToolCallProps {
args: TaskToolArgs;
result?: TaskToolSuccessResult;
Expand All @@ -173,28 +181,45 @@ export const TaskToolCall: React.FC<TaskToolCallProps> = ({ args, result, status
const hasReport = result?.status === "completed" && !!result.reportMarkdown;
const { expanded, toggleExpanded } = useToolExpansion(hasReport);

const isBackground = args.run_in_background ?? false;
const agentType = args.subagent_type;
const prompt = args.prompt;
const title = args.title;
const isBackground = args.run_in_background;

let isBashTask: boolean;
let title: string;
let promptOrScript: string;
let kindBadge: React.ReactNode;

if (isBashTaskArgs(args)) {
isBashTask = true;
title = args.display_name ?? "Bash task";
promptOrScript = args.script ?? "";
kindBadge = <AgentTypeBadge type="bash" />;
} else {
isBashTask = false;
title = args.title ?? "Task";
promptOrScript = args.prompt ?? "";
kindBadge = <AgentTypeBadge type={args.subagent_type ?? "explore"} />;
}

// Derive task state from result
const taskId = result?.taskId;
const taskStatus = result?.status;
const reportMarkdown = result?.status === "completed" ? result.reportMarkdown : undefined;
const reportTitle = result?.status === "completed" ? result.title : undefined;
const exitCode = result?.status === "completed" ? result.exitCode : undefined;

// Show preview of prompt (first line or truncated)
const promptPreview =
prompt.length > 60 ? prompt.slice(0, 60).trim() + "…" : prompt.split("\n")[0];
// Show preview (first line or truncated)
const preview =
promptOrScript.length > 60
? promptOrScript.slice(0, 60).trim() + "…"
: promptOrScript.split("\n")[0];

return (
<ToolContainer expanded={expanded}>
<ToolHeader onClick={toggleExpanded}>
<ExpandIcon expanded={expanded}>▶</ExpandIcon>
<TaskIcon toolName="task" />
<ToolName>task</ToolName>
<AgentTypeBadge type={agentType} />
{kindBadge}
{isBackground && (
<span className="text-backgrounded text-[10px] font-medium">background</span>
)}
Expand All @@ -205,26 +230,33 @@ export const TaskToolCall: React.FC<TaskToolCallProps> = ({ args, result, status
<ToolDetails>
{/* Task info surface */}
<div className="task-surface mt-1 rounded-md p-3">
<div className="task-divider mb-2 flex items-center gap-2 border-b pb-2">
<div className="task-divider mb-2 flex flex-wrap items-center gap-2 border-b pb-2">
<span className="text-task-mode text-[12px] font-semibold">
{reportTitle ?? title}
</span>
{taskId && <TaskId id={taskId} />}
{taskStatus && <TaskStatusBadge status={taskStatus} />}
{exitCode !== undefined && (
<span className="text-muted text-[10px]">exit {exitCode}</span>
)}
</div>

{/* Prompt section */}
{/* Prompt / script */}
<div className="mb-2">
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">Prompt</div>
<div className="text-foreground bg-code-bg max-h-[100px] overflow-y-auto rounded-sm p-2 text-[11px] break-words whitespace-pre-wrap">
{prompt}
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">
{isBashTask ? "Script" : "Prompt"}
</div>
<div className="text-foreground bg-code-bg max-h-[140px] overflow-y-auto rounded-sm p-2 text-[11px] break-words whitespace-pre-wrap">
{promptOrScript}
</div>
</div>

{/* Report section */}
{reportMarkdown && (
<div className="task-divider border-t pt-2">
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">Report</div>
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">
{isBashTask ? "Output" : "Report"}
</div>
<div className="text-[11px]">
<MarkdownRenderer content={reportMarkdown} />
</div>
Expand All @@ -243,7 +275,7 @@ export const TaskToolCall: React.FC<TaskToolCallProps> = ({ args, result, status
)}

{/* Collapsed preview */}
{!expanded && <div className="text-muted mt-1 truncate text-[10px]">{promptPreview}</div>}
{!expanded && <div className="text-muted mt-1 truncate text-[10px]">{preview}</div>}
</ToolContainer>
);
};
Expand All @@ -270,6 +302,12 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
const timeoutSecs = args.timeout_secs;
const results = result?.results ?? [];

const showConfigInfo =
taskIds !== undefined ||
timeoutSecs !== undefined ||
args.filter !== undefined ||
args.filter_exclude === true;

// Summary for header
const completedCount = results.filter((r) => r.status === "completed").length;
const totalCount = results.length;
Expand All @@ -292,10 +330,12 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
<ToolDetails>
<div className="task-surface mt-1 rounded-md p-3">
{/* Config info */}
{(taskIds ?? timeoutSecs) && (
{showConfigInfo && (
<div className="task-divider text-muted mb-2 flex flex-wrap gap-2 border-b pb-2 text-[10px]">
{taskIds && <span>Waiting for: {taskIds.length} task(s)</span>}
{timeoutSecs && <span>Timeout: {timeoutSecs}s</span>}
{taskIds !== undefined && <span>Waiting for: {taskIds.length} task(s)</span>}
{timeoutSecs !== undefined && <span>Timeout: {timeoutSecs}s</span>}
{args.filter !== undefined && <span>Filter: {args.filter}</span>}
{args.filter_exclude === true && <span>Exclude: true</span>}
</div>
)}

Expand Down Expand Up @@ -329,20 +369,35 @@ const TaskAwaitResult: React.FC<{
const reportMarkdown = isCompleted ? result.reportMarkdown : undefined;
const title = isCompleted ? result.title : undefined;

const output = "output" in result ? result.output : undefined;
const note = "note" in result ? result.note : undefined;
const exitCode = "exitCode" in result ? result.exitCode : undefined;
const elapsedMs = "elapsed_ms" in result ? result.elapsed_ms : undefined;

return (
<div className="bg-code-bg rounded-sm p-2">
<div className="mb-1 flex items-center gap-2">
<div className="mb-1 flex flex-wrap items-center gap-2">
<TaskId id={result.taskId} />
<TaskStatusBadge status={result.status} />
{title && <span className="text-foreground text-[11px] font-medium">{title}</span>}
{exitCode !== undefined && <span className="text-muted text-[10px]">exit {exitCode}</span>}
{elapsedMs !== undefined && <span className="text-muted text-[10px]">{elapsedMs}ms</span>}
</div>

{!isCompleted && output && output.length > 0 && (
<div className="text-foreground bg-code-bg max-h-[140px] overflow-y-auto rounded-sm p-2 text-[11px] break-words whitespace-pre-wrap">
{output}
</div>
)}

{reportMarkdown && (
<div className="mt-2 text-[11px]">
<MarkdownRenderer content={reportMarkdown} />
</div>
)}

{note && <div className="text-muted mt-1 text-[10px]">{note}</div>}

{"error" in result && result.error && (
<div className="text-danger mt-1 text-[11px]">{result.error}</div>
)}
Expand Down
5 changes: 5 additions & 0 deletions src/browser/contexts/API.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,13 @@ void mock.module("@orpc/client/message-port", () => ({
}));

void mock.module("@/browser/components/AuthTokenModal", () => ({
// Note: Module mocks leak between bun test files.
// Export all commonly-used symbols to avoid cross-test import errors.
AuthTokenModal: () => null,
getStoredAuthToken: () => null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setStoredAuthToken: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
clearStoredAuthToken: () => {},
}));

Expand Down
14 changes: 10 additions & 4 deletions src/browser/hooks/useVoiceInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ export interface UseVoiceInputResult {
* We hide our voice UI on these devices to avoid redundancy with system dictation.
*/
function hasTouchDictation(): boolean {
if (typeof window === "undefined") return false;
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
if (typeof window === "undefined" || typeof navigator === "undefined") return false;

const maxTouchPoints =
typeof navigator.maxTouchPoints === "number" ? navigator.maxTouchPoints : 0;
const hasTouch = "ontouchstart" in window || maxTouchPoints > 0;

// Touch-only check: most touch devices have native dictation.
// We don't check screen size because iPads are large but still have dictation.
return hasTouch;
Expand All @@ -66,7 +70,9 @@ function hasTouchDictation(): boolean {
const HAS_TOUCH_DICTATION = hasTouchDictation();
const HAS_MEDIA_RECORDER = typeof window !== "undefined" && typeof MediaRecorder !== "undefined";
const HAS_GET_USER_MEDIA =
typeof window !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function";
typeof window !== "undefined" &&
typeof navigator !== "undefined" &&
typeof navigator.mediaDevices?.getUserMedia === "function";

// =============================================================================
// Global Key State Tracking
Expand All @@ -79,7 +85,7 @@ const HAS_GET_USER_MEDIA =
*/
let isSpaceCurrentlyHeld = false;

if (typeof window !== "undefined") {
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener(
"keydown",
(e) => {
Expand Down
Loading