From ef853914d5e5968938d4bbe9dff8e2777b1fde99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Jan 2026 15:10:08 +0700 Subject: [PATCH] feat: improve agent stream view UI, fix history replay timestamps, and enhance planner/worker loop --- package.json | 2 +- packages/app/src/components/agent-list.tsx | 77 ++++++++++------ .../app/src/components/agent-stream-view.tsx | 36 ++++---- packages/app/src/components/message.tsx | 53 ++++++----- packages/app/src/hooks/use-favicon-status.ts | 4 +- packages/app/src/styles/unistyles.ts | 2 +- packages/server/scripts/dev-runner.ts | 2 +- .../server/src/client/daemon-client-v2.ts | 2 + .../server/src/server/agent/agent-manager.ts | 5 +- packages/server/src/server/index.ts | 7 +- packages/server/src/shared/messages.ts | 8 +- packages/server/src/tasks/cli.ts | 88 +++++++++++++------ 12 files changed, 179 insertions(+), 107 deletions(-) diff --git a/package.json b/package.json index 51418cf0e..c0058238c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "packages/website" ], "scripts": { - "dev": "npm run dev --workspace=@paseo/server", + "dev": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts", "dev:app": "npm run start --workspace=@paseo/app", "dev:website": "npm run dev --workspace=@paseo/website", "build": "npm run build --workspaces --if-present", diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index f94405100..46270edd9 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,11 +1,22 @@ -import { View, Text, Pressable, Modal, RefreshControl, FlatList, type ListRenderItem } from "react-native"; +import { + View, + Text, + Pressable, + Modal, + RefreshControl, + FlatList, + type ListRenderItem, +} from "react-native"; import { useCallback, useState, type ReactElement } from "react"; import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { formatTimeAgo } from "@/utils/time"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; -import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing"; +import { + buildAgentNavigationKey, + startNavigationTiming, +} from "@/utils/navigation-timing"; interface AgentListProps { agents: AggregatedAgent[]; @@ -30,7 +41,9 @@ export function AgentList({ // Get the methods for the specific server const methods = useSessionStore((state) => - actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined + actionAgent?.serverId + ? state.sessions[actionAgent.serverId]?.methods + : undefined ); const deleteAgent = methods?.deleteAgent; @@ -94,7 +107,11 @@ export function AgentList({ const isRunning = agent.status === "running"; const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; - const statusColor = isRunning ? theme.colors.palette.blue[500] : agent.requiresAttention ? theme.colors.success : null; + const statusColor = isRunning + ? theme.colors.palette.blue[500] + : agent.requiresAttention + ? theme.colors.success + : null; return ( handleAgentLongPress(agent)} > {({ hovered }) => ( - - - {statusColor && ( - - )} - - {agent.title || "New Agent"} + + + {statusColor && ( + + )} + + {agent.title || "New Agent"} + + + + + {agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} · {timeAgo} - - - {agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} · {timeAgo} - - )} ); @@ -176,7 +195,10 @@ export function AgentList({ onRequestClose={handleCloseActionSheet} > - + @@ -195,7 +217,8 @@ export function AgentList({ {isActionDaemonUnavailable ? "Host offline" : "Delete agent"} @@ -246,7 +269,7 @@ const styles = StyleSheet.create((theme) => ({ row: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: theme.spacing[0], }, agentTitle: { flex: 1, @@ -255,10 +278,12 @@ const styles = StyleSheet.create((theme) => ({ xs: "400", md: "300", }, - color: theme.colors.foregroundMuted, + color: theme.colors.foreground, + opacity: 0.8, }, agentTitleHighlighted: { color: theme.colors.foreground, + opacity: 1, }, secondaryRow: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 55544264b..17b68c2f7 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -373,8 +373,6 @@ export function AgentStreamView({ return null; } - const gap = getGapAbove(item, index); - // Check if this is the end of a turn (before a user message or end of stream when not running) // In inverted list: index-1 is the next item (newer in time) const nextItem = flatListData[index - 1]; @@ -386,18 +384,19 @@ export function AgentStreamView({ const getContent = () => collectTurnContent(index); return ( - + {content} {isEndOfTurn ? : null} ); }, - [getGapAbove, renderStreamItemContent, flatListData, agent.status, collectTurnContent] + [ + getGapAbove, + renderStreamItemContent, + flatListData, + agent.status, + collectTurnContent, + ] ); const pendingPermissionItems = useMemo( @@ -419,7 +418,10 @@ export function AgentStreamView({ } const delta = Math.abs(totalCount - prevCount); streamItemCountRef.current = totalCount; - if (totalCount < STREAM_ITEM_LOG_MIN_COUNT && delta < STREAM_ITEM_LOG_DELTA_THRESHOLD) { + if ( + totalCount < STREAM_ITEM_LOG_MIN_COUNT && + delta < STREAM_ITEM_LOG_DELTA_THRESHOLD + ) { return; } let userCount = 0; @@ -452,7 +454,10 @@ export function AgentStreamView({ break; } } - const metrics = totalCount >= STREAM_ITEM_LOG_MIN_COUNT ? measurePayload(streamItems) : null; + const metrics = + totalCount >= STREAM_ITEM_LOG_MIN_COUNT + ? measurePayload(streamItems) + : null; perfLog(AGENT_STREAM_LOG_TAG, { event: "stream_items", agentId, @@ -945,11 +950,10 @@ function PermissionRequestCard({ const handleResponse = useCallback( (response: AgentPermissionResponse) => { respondToPermission({ - agentId: permission.agentId, - requestId: permission.request.id, - response, - }) - .catch((error) => { + agentId: permission.agentId, + requestId: permission.request.id, + response, + }).catch((error) => { console.error( "[PermissionRequestCard] Failed to respond to permission:", error diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 635439e12..d0c974fc7 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,4 +1,11 @@ -import { View, Text, Pressable, Animated } from "react-native"; +import { + View, + Text, + Pressable, + Animated, + StyleProp, + ViewStyle, +} from "react-native"; import { useState, useEffect, @@ -31,6 +38,7 @@ import { Search, Brain, Copy, + TriangleAlertIcon, } from "lucide-react-native"; import { StyleSheet, @@ -173,14 +181,11 @@ export const UserMessage = memo(function UserMessage({ ({ label: { color: theme.colors.foreground, fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, flexShrink: 0, }, secondaryLabel: { @@ -804,8 +809,7 @@ export const ActivityLog = memo(function ActivityLog({ disabled={!isInteractive} style={[ activityLogStylesheet.pressable, - !resolvedDisableOuterSpacing && - activityLogStylesheet.pressableSpacing, + !resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing, config.bg, isInteractive && activityLogStylesheet.pressableActive, ]} @@ -985,8 +989,7 @@ export const TodoListCard = memo(function TodoListCard({ @@ -1056,6 +1059,7 @@ interface ExpandableBadgeProps { secondaryLabel?: string; icon?: ComponentType<{ size?: number; color?: string }>; isExpanded: boolean; + style?: StyleProp; onToggle?: () => void; renderDetails?: () => ReactNode; isLoading?: boolean; @@ -1066,6 +1070,7 @@ interface ExpandableBadgeProps { const ExpandableBadge = memo(function ExpandableBadge({ label, + style, secondaryLabel, icon, isExpanded, @@ -1118,7 +1123,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ const IconComponent = icon; const iconColor = isError ? theme.colors.destructive - : theme.colors.foreground; + : theme.colors.mutedForeground; let iconNode: ReactNode = null; if (isLoading) { @@ -1128,22 +1133,13 @@ const ExpandableBadge = memo(function ExpandableBadge({ ); } else if (isError) { - iconNode = ; + iconNode = ; } else if (IconComponent) { iconNode = ; } return ( - + null - : undefined + ? () => null + : undefined } isLoading={status === "executing"} isError={status === "failed"} isLastInSequence={isLastInSequence} + style={isLastInSequence ? undefined : { marginBottom: theme.spacing[1] }} disableOuterSpacing={disableOuterSpacing} /> ); diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index a1dec97d2..cb7497fe7 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -67,7 +67,9 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) { } function getSystemColorScheme(): ColorScheme { - if (typeof window === "undefined") return "dark"; + if (Platform.OS !== "web" || typeof window === "undefined" || typeof window.matchMedia !== "function") { + return "dark"; + } return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 648fe73d6..c8595ad2d 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -18,7 +18,7 @@ StyleSheet.configure({ xl: 1200, }, settings: { - initialTheme: "dark", + adaptiveThemes: true, }, }); diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index b2a4f1464..4ac81edf3 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -10,7 +10,7 @@ let child: ChildProcess | null = null; let restarting = false; function spawnServer() { - child = fork(serverEntry, { + child = fork(serverEntry, process.argv.slice(2), { stdio: "inherit", env: process.env, execArgv: ["--import", "tsx"], diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 4e6f05246..bfaaa87dc 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -1408,6 +1408,8 @@ export class DaemonClientV2 { const parsed = WSOutboundMessageSchema.safeParse(parsedJson); if (!parsed.success) { + const msgType = (parsedJson as { message?: { type?: string } })?.message?.type ?? "unknown"; + console.warn(`[DaemonClientV2] Message validation failed for "${msgType}":`, parsed.error.message); return; } diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9c546ba9f..cd4f7c0f7 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -879,7 +879,10 @@ export class AgentManager { event: AgentStreamEvent, options?: { fromHistory?: boolean } ): void { - agent.updatedAt = new Date(); + // Only update timestamp for live events, not history replay + if (!options?.fromHistory) { + agent.updatedAt = new Date(); + } switch (event.type) { case "thread_started": diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 0c2522c89..3f35d34a2 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -1,4 +1,9 @@ -import "dotenv/config"; +import dotenv from "dotenv"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, "../../.env") }); import { createPaseoDaemon } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 386df5374..f8b67721b 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -430,14 +430,10 @@ export const GitDiffRequestSchema = z.object({ }); // Highlighted diff token schema +// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter const HighlightTokenSchema = z.object({ text: z.string(), - style: z.enum([ - "keyword", "comment", "string", "number", "literal", - "function", "definition", "class", "type", "tag", - "attribute", "property", "variable", "operator", - "punctuation", "regexp", "escape", "meta", "heading", "link", - ]).nullable(), + style: z.string().nullable(), }); const DiffLineSchema = z.object({ diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index 2de34dbcf..a1e8feb62 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -468,6 +468,8 @@ function runAgentWithModel( "exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", + "-c", + "model_reasoning_effort=\"medium\"", ]; if (config.model) { args.push("--model", config.model); @@ -540,11 +542,28 @@ function makePlannerPrompt(task: Task, context: string, iteration: number): stri --- -You are a PLANNER agent. Your job is to analyze the task tree and organize work to achieve the top-level goal. +You are a PLANNER agent. You ORCHESTRATE work - you do NOT do the work yourself. + +Your job is to organize tasks so that WORKER agents can accomplish the top-level acceptance criteria. Current iteration: ${iteration} -You have FULL ACCESS to the task CLI and can reorganize the ENTIRE task tree under the scope: +## Planning vs Working + +As a planner you MAY: +- Look at file structure and test patterns to write good acceptance criteria +- Read the task tree to understand what's been done and what needs reorganizing +- Check naming conventions to make specific, verifiable criteria + +As a planner you must NOT: +- Investigate WHY bugs happen (workers will do this) +- Debug or trace through code (workers will do this) +- Try to understand root causes (workers will do this) +- Fix or implement anything (workers will do this) + +Take the task descriptions AS GIVEN and create well-organized subtasks for workers to investigate and implement. + +## Task CLI Commands - \`task show ${task.id}\` - view task details with parent context - \`task list\` - see all tasks - \`task tree ${task.id}\` - see full task hierarchy @@ -694,18 +713,17 @@ The ONLY question: Does the acceptance criterion pass when verified? Yes or No. If an agent left notes explaining why something couldn't be done, IGNORE THE EXPLANATION. Just check: is the criterion met? -## Output Format +## Output Format - CRITICAL -After verification, output EXACTLY one of these XML tags: +You MUST output one of these XML tags at the END of your response. This is REQUIRED for the system to parse your verdict: -If ALL criteria pass verification: +If ALL criteria pass: DONE -If ANY criterion fails verification: +If ANY criterion fails: NOT_DONE - -- criterion that failed: what you checked and what you found - + +If you do not include this exact XML tag, your verdict will not be recorded and the task will retry. Then add a note to the task with your findings: \`task note ${task.id} "Judge verdict: [DONE/NOT_DONE]. Details: ..."\` @@ -785,10 +803,42 @@ program }; const runTaskLoop = async (): Promise => { + // First check for in_progress tasks (resuming from crash) + const allTasks = await store.list(); + let candidates = scopeId + ? [await store.get(scopeId), ...(await store.getDescendants(scopeId))].filter(Boolean) as Task[] + : allTasks; + + const inProgress = candidates.filter((t) => t.status === "in_progress"); + if (inProgress.length > 0) { + log(logFile, `Found ${inProgress.length} in_progress task(s) from previous run, resuming...`); + for (const t of inProgress) { + await store.update(t.id, { status: "open" }); + log(logFile, `Reset ${t.id} to open`); + } + } + + // Step 1: Initial planner run on scope root (if enabled) + if (enablePlanner && scopeId) { + const scopeTask = await store.get(scopeId); + if (scopeTask) { + log(logFile, `[PLANNER] Initial planning on scope root...`); + await runPlanner(scopeTask, "initial planning"); + log(logFile, `[DEBUG] Planner finished, continuing to worker loop`); + } + } + + log(logFile, `[DEBUG] Entering worker loop`); + + // Step 2: Worker/Judge loop on ready tasks while (true) { - // Find ready tasks + log(logFile, `[DEBUG] Checking for ready tasks...`); const ready = await store.getReady(scopeId); - if (ready.length === 0) break; + log(logFile, `[DEBUG] Found ${ready.length} ready tasks`); + if (ready.length === 0) { + log(logFile, `[DEBUG] No ready tasks, exiting loop`); + break; + } const task = ready[0]; const workerModel = (task.assignee === "codex" ? "gpt-5.2" : "sonnet") as ModelName; @@ -796,20 +846,7 @@ program log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`); await store.start(task.id); - // Step 1: Initial planner run (if enabled) - if (enablePlanner) { - const shouldRestart = await runPlanner(task, "initial planning"); - if (shouldRestart) { - // Reset task to open so we process children first - const currentTask = await store.get(task.id); - if (currentTask && currentTask.status === "in_progress") { - await store.update(task.id, { status: "open" }); - } - continue; - } - } - - // Step 2: Worker/Judge loop + // Worker/Judge loop let iteration = 1; let taskDone = false; @@ -852,6 +889,7 @@ program const scopeTask = scopeId ? await store.get(scopeId) : nextReady[0]; if (scopeTask) { await runPlanner(scopeTask, "periodic reassessment"); + log(logFile, `[DEBUG] Periodic replan finished, continuing loop`); } } }