From 119a95169de925eebb5386c5ec8c0af07ff6ce10 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Tue, 14 Apr 2026 23:33:49 +0100 Subject: [PATCH 01/39] fix: Introduce dev sign in bypass --- .claude/settings.json | 5 +- apps/mobile/src/app/auth.tsx | 86 ++++++++++++++++++- .../src/features/auth/stores/authStore.ts | 42 ++++++++- 3 files changed, 124 insertions(+), 9 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index a98a38348..e73b7b901 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,10 +4,7 @@ "deny": [ "Read(./apps/cli/**)", "Edit(./apps/cli/**)", - "Write(./apps/cli/**)", - "Read(./apps/mobile/**)", - "Edit(./apps/mobile/**)", - "Write(./apps/mobile/**)" + "Write(./apps/cli/**)" ] } } diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index b5c8f9838..e0678cfdf 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -1,6 +1,13 @@ import { router } from "expo-router"; import { useMemo, useState } from "react"; -import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native"; +import { + ActivityIndicator, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { type CloudRegion, useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; @@ -28,8 +35,31 @@ export default function AuthScreen() { const [selectedRegion, setSelectedRegion] = useState("us"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [devToken, setDevToken] = useState(""); + const [devProjectId, setDevProjectId] = useState(""); - const { loginWithOAuth } = useAuthStore(); + const { loginWithOAuth, loginWithPersonalApiKey } = useAuthStore(); + + const handleDevSignIn = async () => { + setIsLoading(true); + setError(null); + try { + const projectIdNum = Number(devProjectId); + if (!Number.isFinite(projectIdNum) || projectIdNum <= 0) { + throw new Error("Project ID must be a positive number"); + } + await loginWithPersonalApiKey({ + token: devToken, + projectId: projectIdNum, + region: selectedRegion, + }); + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to sign in"); + } finally { + setIsLoading(false); + } + }; const handleSignIn = async () => { setIsLoading(true); @@ -57,7 +87,11 @@ export default function AuthScreen() { return ( - + {/* Header */} @@ -131,8 +165,52 @@ export default function AuthScreen() { )} + + {__DEV__ && ( + + + Dev sign-in (personal API key) + + + Skips OAuth. Create a personal API key at Settings → User API + keys with scopes: user:read, project:read, task:write, + integration:read, conversation:write, query:read. + + + + + + Dev sign in + + + + )} - + ); } diff --git a/apps/mobile/src/features/auth/stores/authStore.ts b/apps/mobile/src/features/auth/stores/authStore.ts index a9d105dbe..79a029415 100644 --- a/apps/mobile/src/features/auth/stores/authStore.ts +++ b/apps/mobile/src/features/auth/stores/authStore.ts @@ -29,6 +29,11 @@ interface AuthState { // Methods loginWithOAuth: (region: CloudRegion) => Promise; + loginWithPersonalApiKey: (params: { + token: string; + projectId: number; + region: CloudRegion; + }) => Promise; refreshAccessToken: () => Promise; scheduleTokenRefresh: () => void; initializeAuth: () => Promise; @@ -96,6 +101,40 @@ export const useAuthStore = create()( get().scheduleTokenRefresh(); }, + loginWithPersonalApiKey: async ({ token, projectId, region }) => { + if (!__DEV__) { + throw new Error( + "Dev sign-in is only available in development builds", + ); + } + const trimmed = token.trim(); + if (!trimmed) { + throw new Error("Personal API key is required"); + } + if (!Number.isFinite(projectId) || projectId <= 0) { + throw new Error("Valid project ID is required"); + } + + const storedTokens: StoredTokens = { + accessToken: trimmed, + refreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudRegion: region, + scopedTeams: [projectId], + }; + + await saveTokens(storedTokens); + + set({ + oauthAccessToken: trimmed, + oauthRefreshToken: null, + tokenExpiry: null, + cloudRegion: region, + projectId, + isAuthenticated: true, + }); + }, + refreshAccessToken: async () => { const state = get(); @@ -140,7 +179,8 @@ export const useAuthStore = create()( refreshTimeoutId = null; } - if (!state.tokenExpiry) { + // Personal API key sessions have no refresh token — nothing to schedule. + if (!state.tokenExpiry || !state.oauthRefreshToken) { return; } From 25780fa71df1dcb22e7b5a50fa135bde2b7b5e48 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Tue, 14 Apr 2026 23:33:49 +0100 Subject: [PATCH 02/39] fix: Introduce dev sign in bypass --- .claude/settings.json | 5 +- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + .../services/agent/local-command-receiver.ts | 262 ++++++ apps/code/src/main/services/agent/schemas.ts | 30 +- .../src/main/services/agent/service.test.ts | 123 ++- apps/code/src/main/services/agent/service.ts | 122 +++ apps/code/src/main/trpc/routers/agent.ts | 20 + .../features/sessions/service/service.test.ts | 3 + .../features/sessions/service/service.ts | 27 + apps/mobile/app.json | 10 +- apps/mobile/assets/sounds/meep.mp3 | Bin 0 -> 7941 bytes apps/mobile/index.js | 1 + apps/mobile/package.json | 3 +- apps/mobile/src/app/(tabs)/_layout.tsx | 30 +- apps/mobile/src/app/(tabs)/index.tsx | 8 +- apps/mobile/src/app/(tabs)/settings.tsx | 36 + apps/mobile/src/app/(tabs)/tasks.tsx | 39 +- apps/mobile/src/app/_layout.tsx | 44 +- apps/mobile/src/app/auth.tsx | 86 +- apps/mobile/src/app/task/[id].tsx | 185 +++- apps/mobile/src/app/task/index.tsx | 83 +- .../src/features/auth/stores/authStore.ts | 42 +- .../features/chat/components/AgentMessage.tsx | 5 +- .../src/features/chat/components/Composer.tsx | 143 +++- .../features/chat/components/MarkdownText.tsx | 317 +++++++ .../features/chat/components/ToolMessage.tsx | 798 +++++++++++++++++- .../features/chat/hooks/useVoiceRecording.ts | 212 +++-- apps/mobile/src/features/chat/index.ts | 2 +- .../preferences/stores/preferencesStore.ts | 29 + apps/mobile/src/features/tasks/api.ts | 165 +++- .../tasks/components/PlanStatusBar.tsx | 88 ++ .../tasks/components/QuestionCard.tsx | 378 +++++++++ .../tasks/components/SwipeableTaskItem.tsx | 143 ++++ .../features/tasks/components/TaskItem.tsx | 15 +- .../features/tasks/components/TaskList.tsx | 101 ++- .../tasks/components/TaskSessionView.tsx | 727 ++++++++++++++-- .../src/features/tasks/hooks/useTasks.ts | 17 +- .../tasks/stores/archivedTasksStore.ts | 40 + .../features/tasks/stores/taskSessionStore.ts | 680 ++++++++++++--- apps/mobile/src/features/tasks/types.ts | 13 + .../features/tasks/utils/parseSessionLogs.ts | 17 - .../mobile/src/features/tasks/utils/sounds.ts | 23 + packages/agent/src/acp-extensions.ts | 8 + .../claude/permissions/permission-handlers.ts | 35 +- packages/agent/src/server/agent-server.ts | 139 ++- .../agent/src/server/question-relay.test.ts | 7 +- pnpm-lock.yaml | 16 + 48 files changed, 4708 insertions(+), 572 deletions(-) create mode 100644 apps/code/src/main/services/agent/local-command-receiver.ts create mode 100644 apps/mobile/assets/sounds/meep.mp3 create mode 100644 apps/mobile/index.js create mode 100644 apps/mobile/src/features/chat/components/MarkdownText.tsx create mode 100644 apps/mobile/src/features/preferences/stores/preferencesStore.ts create mode 100644 apps/mobile/src/features/tasks/components/PlanStatusBar.tsx create mode 100644 apps/mobile/src/features/tasks/components/QuestionCard.tsx create mode 100644 apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx create mode 100644 apps/mobile/src/features/tasks/stores/archivedTasksStore.ts create mode 100644 apps/mobile/src/features/tasks/utils/sounds.ts diff --git a/.claude/settings.json b/.claude/settings.json index a98a38348..e73b7b901 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,10 +4,7 @@ "deny": [ "Read(./apps/cli/**)", "Edit(./apps/cli/**)", - "Write(./apps/cli/**)", - "Read(./apps/mobile/**)", - "Edit(./apps/mobile/**)", - "Write(./apps/mobile/**)" + "Write(./apps/cli/**)" ] } } diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index f45163c53..2a688ba2a 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -10,6 +10,7 @@ import { WorkspaceRepository } from "../db/repositories/workspace-repository"; import { WorktreeRepository } from "../db/repositories/worktree-repository"; import { DatabaseService } from "../db/service"; import { AgentAuthAdapter } from "../services/agent/auth-adapter"; +import { LocalCommandReceiver } from "../services/agent/local-command-receiver"; import { AgentService } from "../services/agent/service"; import { AppLifecycleService } from "../services/app-lifecycle/service"; import { ArchiveService } from "../services/archive/service"; @@ -64,6 +65,7 @@ container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository); container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl); container.bind(MAIN_TOKENS.AgentAuthAdapter).to(AgentAuthAdapter); container.bind(MAIN_TOKENS.AgentService).to(AgentService); +container.bind(MAIN_TOKENS.LocalCommandReceiver).to(LocalCommandReceiver); container.bind(MAIN_TOKENS.AuthService).to(AuthService); container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService); container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 27bdbcafc..5308d78f4 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -21,6 +21,7 @@ export const MAIN_TOKENS = Object.freeze({ // Services AgentAuthAdapter: Symbol.for("Main.AgentAuthAdapter"), AgentService: Symbol.for("Main.AgentService"), + LocalCommandReceiver: Symbol.for("Main.LocalCommandReceiver"), AuthService: Symbol.for("Main.AuthService"), AuthProxyService: Symbol.for("Main.AuthProxyService"), ArchiveService: Symbol.for("Main.ArchiveService"), diff --git a/apps/code/src/main/services/agent/local-command-receiver.ts b/apps/code/src/main/services/agent/local-command-receiver.ts new file mode 100644 index 000000000..8a3b1d186 --- /dev/null +++ b/apps/code/src/main/services/agent/local-command-receiver.ts @@ -0,0 +1,262 @@ +import { inject, injectable, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import type { AuthService } from "../auth/service"; + +const log = logger.scope("local-command-receiver"); +const INITIAL_RECONNECT_DELAY_MS = 2000; +const MAX_RECONNECT_DELAY_MS = 30_000; +// After this many consecutive failures, assume Last-Event-ID is stale (event +// trimmed from the backend buffer) and fall back to a fresh connect with +// start=latest. Accepts that we may drop commands issued during the outage. +const STALE_EVENT_ID_THRESHOLD = 3; + +/** + * JSON-RPC envelope carried inside an `incoming_command` SSE event. The + * backend repackages whatever mobile POSTs to /command/ into this shape + * (see products/tasks/backend/api.py :: command). + */ +export interface IncomingCommandPayload { + jsonrpc: string; + method: string; + params?: { content?: string } & Record; + id?: string | number; +} + +interface SubscribeParams { + taskId: string; + taskRunId: string; + projectId: number; + apiHost: string; + onCommand: (payload: IncomingCommandPayload) => Promise; +} + +interface Subscription { + taskRunId: string; + controller: AbortController; +} + +/** + * Subscribes to the PostHog task-run SSE stream for a local run and + * delivers `incoming_command` events (published by the backend when mobile + * POSTs to /command/ on a run with environment=local) to a caller-supplied + * callback. + * + * One SSE connection per subscribed run. Reconnects with backoff on failure. + * Uses the `Last-Event-ID` header to resume from the last processed event + * so brief network blips don't drop commands. + */ +@injectable() +export class LocalCommandReceiver { + private readonly subs = new Map(); + + constructor( + @inject(MAIN_TOKENS.AuthService) + private readonly auth: AuthService, + ) {} + + subscribe(params: SubscribeParams): void { + if (this.subs.has(params.taskRunId)) { + log.debug("Already subscribed", { taskRunId: params.taskRunId }); + return; + } + const controller = new AbortController(); + this.subs.set(params.taskRunId, { + taskRunId: params.taskRunId, + controller, + }); + log.info("Subscribing to SSE stream", { taskRunId: params.taskRunId }); + void this.connectLoop(params, controller).catch((err) => { + if (controller.signal.aborted) return; + log.error("Connect loop exited unexpectedly", { + taskRunId: params.taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + unsubscribe(taskRunId: string): void { + const sub = this.subs.get(taskRunId); + if (!sub) return; + sub.controller.abort(); + this.subs.delete(taskRunId); + log.info("Unsubscribed", { taskRunId }); + } + + @preDestroy() + async shutdown(): Promise { + // Abort before awaiting teardown — per async-cleanup-ordering guidance. + for (const sub of this.subs.values()) sub.controller.abort(); + this.subs.clear(); + } + + private async connectLoop( + params: SubscribeParams, + controller: AbortController, + ): Promise { + let lastEventId: string | undefined; + let consecutiveFailures = 0; + + while (!controller.signal.aborted) { + let streamOpened = false; + try { + const { accessToken } = await this.auth.getValidAccessToken(); + const url = new URL( + `${params.apiHost}/api/projects/${params.projectId}/tasks/${params.taskId}/runs/${params.taskRunId}/stream/`, + ); + if (!lastEventId) { + // Fresh connect: only care about events published from now on. + // On reconnect we use Last-Event-ID instead (see headers below). + url.searchParams.set("start", "latest"); + } + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "text/event-stream", + }; + if (lastEventId) headers["Last-Event-ID"] = lastEventId; + + const response = await fetch(url.toString(), { + headers, + signal: controller.signal, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `SSE HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`, + ); + } + + streamOpened = true; + consecutiveFailures = 0; + lastEventId = await this.readEventStream( + response.body, + params.onCommand, + controller.signal, + lastEventId, + ); + log.info("SSE stream ended cleanly", { + taskRunId: params.taskRunId, + }); + } catch (err) { + if (controller.signal.aborted) return; + if (!streamOpened) consecutiveFailures++; + if ( + consecutiveFailures >= STALE_EVENT_ID_THRESHOLD && + lastEventId !== undefined + ) { + log.warn( + "Dropping possibly-stale Last-Event-ID after repeated failures", + { + taskRunId: params.taskRunId, + consecutiveFailures, + }, + ); + lastEventId = undefined; + } + log.warn("SSE disconnected, will reconnect", { + taskRunId: params.taskRunId, + consecutiveFailures, + error: err instanceof Error ? err.message : String(err), + }); + } + if (controller.signal.aborted) return; + const delay = Math.min( + MAX_RECONNECT_DELAY_MS, + INITIAL_RECONNECT_DELAY_MS * 2 ** Math.max(0, consecutiveFailures - 1), + ); + await this.sleep(delay, controller.signal); + } + } + + private async readEventStream( + body: ReadableStream | null, + onCommand: SubscribeParams["onCommand"], + signal: AbortSignal, + seedLastEventId: string | undefined, + ): Promise { + if (!body) throw new Error("Missing SSE response body"); + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastEventId = seedLastEventId; + + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) return lastEventId; + buffer += decoder.decode(value, { stream: true }); + + // SSE event blocks are separated by a blank line (\n\n). + while (true) { + const separator = buffer.indexOf("\n\n"); + if (separator === -1) break; + const rawEvent = buffer.slice(0, separator); + buffer = buffer.slice(separator + 2); + + let dataChunks = ""; + let eventId: string | undefined; + for (const line of rawEvent.split("\n")) { + if (line.startsWith("data: ")) { + dataChunks += line.slice(6); + } else if (line.startsWith("id: ")) { + eventId = line.slice(4); + } + // `event:` and comments are ignored — we route on data.type. + } + if (!dataChunks) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(dataChunks); + } catch { + log.warn("Failed to parse SSE data chunk", { + preview: dataChunks.slice(0, 120), + }); + continue; + } + + if ( + typeof parsed === "object" && + parsed !== null && + (parsed as { type?: unknown }).type === "incoming_command" + ) { + const payload = (parsed as { payload?: unknown }).payload; + if (payload && typeof payload === "object") { + try { + await onCommand(payload as IncomingCommandPayload); + } catch (err) { + log.error("Incoming command handler threw", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + if (eventId) lastEventId = eventId; + } + } + return lastEventId; + } finally { + try { + await reader.cancel(); + } catch { + // Reader already closed or cancelled; nothing to do. + } + } + } + + private sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + } +} diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 3ead6cf15..de161a862 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -16,24 +16,6 @@ export const credentialsSchema = z.object({ export type Credentials = z.infer; -// Session config schema -export const sessionConfigSchema = z.object({ - taskId: z.string(), - taskRunId: z.string(), - repoPath: z.string(), - credentials: credentialsSchema, - logUrl: z.string().optional(), - /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ - sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), - /** Additional directories Claude can access beyond cwd (for worktree support) */ - additionalDirectories: z.array(z.string()).optional(), - /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ - permissionMode: z.string().optional(), -}); - -export type SessionConfig = z.infer; - // Start session input/output export const startSessionInput = z.object({ @@ -175,6 +157,7 @@ export const reconnectSessionInput = z.object({ customInstructions: z.string().max(2000).optional(), effort: effortLevelSchema.optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), + runMode: z.enum(["local", "cloud"]).optional(), }); export type ReconnectSessionInput = z.infer; @@ -200,6 +183,11 @@ export const recordActivityInput = z.object({ export const AgentServiceEvent = { SessionEvent: "session-event", PermissionRequest: "permission-request", + // Fires when a pending permission is resolved by anything other than the + // Electron UI (e.g. a mobile client calling permission_response). Renderer + // uses this to clear its own pendingPermissions copy in lockstep with the + // main-process map. + PermissionResolved: "permission-resolved", SessionsIdle: "sessions-idle", SessionIdleKilled: "session-idle-killed", AgentFileActivity: "agent-file-activity", @@ -228,9 +216,15 @@ export interface AgentFileActivityPayload { branchName: string | null; } +export interface PermissionResolvedPayload { + taskRunId: string; + toolCallId: string; +} + export interface AgentServiceEvents { [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; + [AgentServiceEvent.PermissionResolved]: PermissionResolvedPayload; [AgentServiceEvent.SessionsIdle]: undefined; [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 02e1642cc..8cc5789f7 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -176,6 +176,11 @@ function createMockDependencies() { notifyToolResult: vi.fn(), notifyToolCancelled: vi.fn(), }, + localCommandReceiver: { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, }; } @@ -189,11 +194,12 @@ const baseSessionParams = { describe("AgentService", () => { let service: AgentService; + let deps: ReturnType; beforeEach(() => { vi.clearAllMocks(); - const deps = createMockDependencies(); + deps = createMockDependencies(); service = new AgentService( deps.processTracking as never, deps.sleepService as never, @@ -201,6 +207,7 @@ describe("AgentService", () => { deps.posthogPluginService as never, deps.agentAuthAdapter as never, deps.mcpAppsService as never, + deps.localCommandReceiver as never, ); }); @@ -439,4 +446,118 @@ describe("AgentService", () => { ); }); }); + + describe("local runMode", () => { + it("subscribes to local command receiver when runMode is local", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + expect(deps.localCommandReceiver.subscribe).toHaveBeenCalledTimes(1); + const call = deps.localCommandReceiver.subscribe.mock.calls[0][0]; + expect(call).toMatchObject({ + taskId: "task-1", + taskRunId: "run-1", + projectId: 1, + apiHost: "https://app.posthog.com", + }); + expect(typeof call.onCommand).toBe("function"); + }); + + it("does not subscribe when runMode is cloud", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "cloud", + }); + + expect(deps.localCommandReceiver.subscribe).not.toHaveBeenCalled(); + }); + + it("delivers user_message commands to the session via prompt", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ + jsonrpc: "2.0", + method: "user_message", + params: { content: "hello from mobile" }, + }); + + expect(promptSpy).toHaveBeenCalledWith("run-1", [ + { type: "text", text: "hello from mobile" }, + ]); + }); + + it("ignores non-user_message commands", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ + jsonrpc: "2.0", + method: "something_else", + params: { content: "ignored" }, + }); + + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it("ignores commands with missing or non-string content", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ jsonrpc: "2.0", method: "user_message", params: {} }); + await onCommand({ + jsonrpc: "2.0", + method: "user_message", + params: { content: "" }, + }); + + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it("unsubscribes on session cleanup", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + await ( + service as unknown as { + cleanupSession: (id: string) => Promise; + } + ).cleanupSession("run-1"); + + expect(deps.localCommandReceiver.unsubscribe).toHaveBeenCalledWith( + "run-1", + ); + }); + }); }); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index d2fb6eed3..ad2cadd40 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -51,6 +51,7 @@ import type { ProcessTrackingService } from "../process-tracking/service"; import type { SleepService } from "../sleep/service"; import type { AgentAuthAdapter } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; +import type { LocalCommandReceiver } from "./local-command-receiver"; import { AgentServiceEvent, type AgentServiceEvents, @@ -257,6 +258,8 @@ interface SessionConfig { model?: string; /** JSON Schema for structured task output — when set, the agent gets a create_output tool */ jsonSchema?: Record | null; + /** Whether this session runs locally on the desktop or in a cloud sandbox */ + runMode?: "local" | "cloud"; } interface ManagedSession { @@ -324,6 +327,7 @@ export class AgentService extends TypedEventEmitter { private posthogPluginService: PosthogPluginService; private agentAuthAdapter: AgentAuthAdapter; private mcpAppsService: McpAppsService; + private localCommandReceiver: LocalCommandReceiver; constructor( @inject(MAIN_TOKENS.ProcessTrackingService) @@ -338,6 +342,8 @@ export class AgentService extends TypedEventEmitter { agentAuthAdapter: AgentAuthAdapter, @inject(MAIN_TOKENS.McpAppsService) mcpAppsService: McpAppsService, + @inject(MAIN_TOKENS.LocalCommandReceiver) + localCommandReceiver: LocalCommandReceiver, ) { super(); this.processTracking = processTracking; @@ -346,6 +352,7 @@ export class AgentService extends TypedEventEmitter { this.posthogPluginService = posthogPluginService; this.agentAuthAdapter = agentAuthAdapter; this.mcpAppsService = mcpAppsService; + this.localCommandReceiver = localCommandReceiver; powerMonitor.on("resume", () => this.checkIdleDeadlines()); } @@ -390,6 +397,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -418,6 +426,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -822,6 +831,15 @@ When creating pull requests, add the following footer at the end of the PR descr this.sessions.set(taskRunId, session); this.recordActivity(taskRunId); + if (config.runMode === "local") { + this.ensureLocalCommandSubscription( + taskId, + taskRunId, + credentials.projectId, + credentials.apiHost, + ); + } + if (isRetry) { log.info("Session created after auth retry", { taskRunId }); } @@ -1161,7 +1179,110 @@ For git operations while detached: session.inFlightMcpToolCalls.clear(); } + /** + * Idempotently subscribe the LocalCommandReceiver to a task-run's SSE + * stream so mobile-originated /command/ calls reach this session. Called + * both on fresh session creation and when an existing session is reused + * — the receiver itself short-circuits duplicate subscribes, so multiple + * calls are safe. Without this, a session that existed before this code + * path shipped (or that had its subscription torn down by an earlier + * cleanup) would silently drop mobile commands. + */ + private ensureLocalCommandSubscription( + taskId: string, + taskRunId: string, + projectId: number, + apiHost: string, + ): void { + this.localCommandReceiver.subscribe({ + taskId, + taskRunId, + projectId, + apiHost, + onCommand: async (payload) => { + log.debug("Local command received", { + taskRunId, + method: payload.method, + }); + + // Mobile (or any external client) answering an outstanding + // requestPermission call. Route it directly to the pending + // promise rather than treating it as a new prompt — otherwise + // the agent stays blocked inside the current turn and the + // answer starts a second turn that can never run. + if (payload.method === "permission_response") { + const params = payload.params ?? {}; + const toolCallId = + typeof params.toolCallId === "string" + ? params.toolCallId + : undefined; + const optionId = + typeof params.optionId === "string" ? params.optionId : undefined; + const customInput = + typeof params.customInput === "string" + ? params.customInput + : undefined; + const rawAnswers = params.answers; + const answers = + rawAnswers && typeof rawAnswers === "object" + ? (rawAnswers as Record) + : undefined; + if (!toolCallId || !optionId) { + log.warn("Invalid permission_response from external client", { + taskRunId, + hasToolCallId: !!toolCallId, + hasOptionId: !!optionId, + }); + return; + } + try { + this.respondToPermission( + taskRunId, + toolCallId, + optionId, + customInput, + answers, + ); + } catch (err) { + log.error("Failed to apply external permission_response", { + taskRunId, + toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + } + return; + } + + if (payload.method !== "user_message") { + log.debug("Ignoring non-user_message local command", { + method: payload.method, + taskRunId, + }); + return; + } + const content = payload.params?.content; + if (typeof content !== "string" || content.length === 0) { + log.warn("Local command missing content", { taskRunId }); + return; + } + try { + await this.prompt(taskRunId, [{ type: "text", text: content }]); + } catch (err) { + log.error("Failed to deliver local command to session", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + }); + } + private async cleanupSession(taskRunId: string): Promise { + // Abort any outstanding SSE subscription for this run first — per + // async-cleanup-ordering guidance, we release external resources + // before awaiting anything that depends on the session being gone. + this.localCommandReceiver.unsubscribe(taskRunId); + const session = this.sessions.get(taskRunId); if (session) { this.cancelInFlightMcpToolCalls(session); @@ -1484,6 +1605,7 @@ For git operations while detached: effort: "effort" in params ? params.effort : undefined, model: "model" in params ? params.model : undefined, jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, + runMode: "runMode" in params ? params.runMode : undefined, }; } diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 98c20a8ce..835845332 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -105,6 +105,26 @@ export const agentRouter = router({ } }), + // Permission resolved subscription - yields when a pending permission gets + // answered through a path other than the local UI (e.g. a mobile client). + // The renderer uses this to clear its mirror of pendingPermissions. + onPermissionResolved: publicProcedure + .input(subscribeSessionInput) + .subscription(async function* (opts) { + const service = getService(); + const targetTaskRunId = opts.input.taskRunId; + const iterable = service.toIterable( + AgentServiceEvent.PermissionResolved, + { signal: opts.signal }, + ); + + for await (const event of iterable) { + if (event.taskRunId === targetTaskRunId) { + yield event; + } + } + }), + // Respond to a permission request from the UI respondToPermission: publicProcedure .input(respondToPermissionInput) diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index a47d7ee56..e7d235e8a 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -16,6 +16,9 @@ const mockTrpcAgent = vi.hoisted(() => ({ cancelPermission: { mutate: vi.fn() }, onSessionEvent: { subscribe: vi.fn() }, onPermissionRequest: { subscribe: vi.fn() }, + onPermissionResolved: { + subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })), + }, onSessionIdleKilled: { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) }, resetAll: { mutate: vi.fn().mockResolvedValue(undefined) }, })); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 75d319dd3..dc5b8e5a6 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -146,6 +146,7 @@ export class SessionService { { event: { unsubscribe: () => void }; permission?: { unsubscribe: () => void }; + permissionResolved?: { unsubscribe: () => void }; } >(); /** Active cloud task watchers, keyed by taskId */ @@ -442,6 +443,7 @@ export class SessionService { adapter: resolvedAdapter, permissionMode: persistedMode, customInstructions: customInstructions || undefined, + runMode: "local", }); if (result) { @@ -616,6 +618,7 @@ export class SessionService { ? (reasoningLevel as EffortLevel) : undefined, model: preferredModel, + runMode: "local", }); const session = this.createBaseSession(taskRun.id, taskId, taskTitle); @@ -725,9 +728,32 @@ export class SessionService { }, ); + // Clears the local pendingPermissions mirror when a permission is + // resolved outside the Electron UI (e.g. a mobile client answers + // the question). Without this, the desktop card would stay visible + // indefinitely even though the agent has already moved on. + const permissionResolvedSubscription = + trpcClient.agent.onPermissionResolved.subscribe( + { taskRunId }, + { + onData: (payload) => { + const session = sessionStoreSetters.getSessions()[taskRunId]; + if (!session) return; + this.resolvePermission(session, payload.toolCallId); + }, + onError: (err) => { + log.error("Permission-resolved subscription error", { + taskRunId, + error: err, + }); + }, + }, + ); + this.subscriptions.set(taskRunId, { event: eventSubscription, permission: permissionSubscription, + permissionResolved: permissionResolvedSubscription, }); } @@ -735,6 +761,7 @@ export class SessionService { const subscription = this.subscriptions.get(taskRunId); subscription?.event.unsubscribe(); subscription?.permission?.unsubscribe(); + subscription?.permissionResolved?.unsubscribe(); this.subscriptions.delete(taskRunId); } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index cb58a4bfc..bfc669988 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -19,6 +19,7 @@ "bundleIdentifier": "com.posthog.mobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", + "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", "ITSAppUsesNonExemptEncryption": false } }, @@ -152,7 +153,14 @@ } } ], - "expo-localization" + "expo-localization", + [ + "expo-speech-recognition", + { + "microphonePermission": "Allow PostHog to use your microphone for voice-to-text input", + "speechRecognitionPermission": "Allow PostHog to transcribe your voice input on-device" + } + ] ], "extra": { "router": {}, diff --git a/apps/mobile/assets/sounds/meep.mp3 b/apps/mobile/assets/sounds/meep.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fd7b4cf7e0afaf5cd9ffb0f03afff9c5adee6ace GIT binary patch literal 7941 zcmchccTiN%p2hDBL(W4E;*bXfBn&yozyL#(EK$ih3kZlZgJcN<3@{|gISGP*h?0|_ z@WUi$nk>3!G z{7HPK5d20ASnszGWI33P_x#*Sou$7UT(YLAPCSM~TPEm#8x}Aoosh&E;BMICRB)nB zd{|Fzq3kS0Xg0whU5dPkbXvw%eNTaO0z4&YqJj$?HI(mm(Q$EZBC-F5{pnQr0QRtV zWlzCHTSjkKul9MbcrNS#Y*z)R%tP}#oGg>h2u}S+3E_`46UKtfCzo7mD8;TTwISzB&e+Oj~8oJ zJK!4SGVCR&i23v>j2OzhUJMQyqk?%g78QMMGt>1cTqla$mJs$;Y^hC8d6>|{`if)M zc-v4p^u|@ycBXIOsntc%b<)&DhJ+m46x!}&X^`Efhi`?2M=UC5AtNjq}A&nT_jSGb`G(YL?)5uv+Zucxx{QlHp#>d31Ff=>*CS&E>Ys%@l0Vz$^ zqHBTzu{@KN@!4a|44N<6o|o_6lBGz^W_yl@Xt#MAvKnqxF?;Gg_F=Z&6m!O-dnbJ! z8oe&@Sd&rUcu{cBum*wv&QbVNx=5NE8Zp<5W>wV70%K7lLt~4Q${hF7Du55Oai|7O$bWZNS)*RIz^s`WGSQ!vpZ(8myPS+>!WYqZ}}r0D|bz*#>gV zjw0s_$Fc8)i7_%sH0+E}9+_+ipKA6iRIy4NZYPnK)znBpG-jEX=USzc>l!BJi&)fL zI<0GXaZBkd&qJZ%$)vpkabh+i3^7R(w_y+S+ZfYX1O1?HQ%LF+{G8#Z%H7IE!0E2b z-RD!Uo~`pVF@L!Zs%>k%1mPy z4(g(!j)iDpI1~9K`8&x)S~sKNJ;=${PU$mv(_>~LKObPt>X7O=u4|NmqFY?WI^&a? zWp=jG`RMP1Z_j@*`J9Ree1!BzVd=hJ6eA@Z!65N2+UkH_Ok7A4KVB~Gi*&N*qpOD~ zmhcMpmF9m21pGya=|WN5$Nt*Lfgu0R^lOVhQVb*V(b??NA;PkPX=2F11Tr$aM}>zz z#@!rdISFMK=vzVISRi5|qG?17vqBP=o<_}tG9(OvVq>fuQ zMh|nGT~w)Jjh?r^l!*;8Jyn^0RrXzxG2KQl{<(Ku8XR7Jxr;x!s8#L%1-HXOIK#c3 zc&dqnMig2N&3eBtU!$WD40aXA5WBuLH z(}j#)v!DCS7Hy{vBoSWuMj}aiwkR-E*2*tyMqH#6$@B5O_0yvG*NthzHH5xlQ<``K zlnypN3V4)HYGPnCSXRsb>-7R1j$R;Hl7=}VyXoCik~n`>2e-GSqifdW8}9mhY56hh zq3)#)#~<4Vto@E(O$<|Lz32X10SW#3w5r62k41V}!j+|GAi?fdSr#AJ!vc|=iSS{O zdjh9y986f+*`Gc3yt*c(IQs!}J66Tl#tsx7a8>}V3c&u%`hQn{5n@qR6rm|QkGv3c z-dgSqu^-~5i0UJgPi8)b9vkQPeUM$DDHV#kL&F^s(_`jqf z=2pVP8^wWbd5UjxUv+a7%=)EtHA@-->7D#YKwtz*T`T)E%j{Bt%6_lE(A@Qan&><2 zJ@k2$;@l`(E@Uv9=E8II(00Wgj>fnmreUJPZO(;3qNR3`9_G41nc~qFx zXKfkQU-;z?8RJ?IGY#WAJQ58O^&3|%FVD+}1gLXd$LRnVCp!*nsMO0-dDK7J55T+p zsJc|qL2BZAG4Jw3U_BucG#thgRp$(ubs7fTBQ7Mu%<6L=UooFy=NEV-P}^X3^g)70Ie%T) z-4poQ@f-uEhcnJTN~=DyZ@vITQ?ALvVW$Z z(GjLW#d5z+wGx`p?9I3NU*z*QV1E&s6H*kbyXU|FsVG}pQI-QRM51VW`wHg9vyYZZ zg+=tjQh68Th3TX~yJ?xdoO7Q1J+^G0tHZAz9uk4?3NAmrn)kwrmxipv;8V>V5V@9f zD)EU}iZKkuDNQ}=XI#JB2h{FLfg{{bUMIN5JbnFZoz!hBd=jMabyhVF)F2;`PjW)Fgv?>XYdTYqHrIgE z^|t{YY5mj{5to;`LKAgXL?nDMFdVw4gm2|DdH|M40U?WIU|_f_1mf#XK@BD8e0&x+ zp)DZsc2;ikPRF!Vcxhu)5Hr^l?=_3H9v;b-c`{hX`O}ap79SlMI(Czx%#CGThX5B! z7X<1}?WC88=h<}sDLO2?;E9DR_iLBi(ND#D9PeGXU!JZz$)Mz^*NmfwAuP|`7w+V^ zY8z3dgKKYKH;Lc_oP~Nsc~^!5&bX1cZ}Bbb?%Y?j7DVuQTiuR1Nf4U5y!^T6Kzz@Q z=>d71P-USVh#LSf|DmV92xZDEN{emn_YzgOu&oHR0iZwM*AWyWymVAK*f_2 z*y~mavOhGZP>fFZDN|1udp};T%d~8j-Muv-a`s!(L0OjhdXnrbscPNa{^Zfv7v(Qs zWC|xxoG{%S-a*(a8a{nMJ0+q%n%QLaGM1In$ibU$w#?2WbG%4aU+CTL?Ip%C>k+I@ zP+%d3u}^Y~tjEMVxEF-6B(Qcz!MMxxK7MDto>;tX3ay@VS-!baDY-Jb0YSM-#u1OQ zd{LTw1Qe_q*}f#xd~6)M;4LA?^^k*@&bs;gN50@7Ny$3%LAX!vxBMB|>*0e3*H%Ve zt+j4%x`Yzu>xS>TY}0NABR#@qbaUV4{yb4nlYM^+U!EzCXBc8z_2=5}IO3(C7Ms*o zotyyCont-VQz0u~Y;z4KKbpCA!&SpBI}jI@6}JU>EFQ;=hc>r`{cE%PJD~}A#M?iF zkb-61`_6Je7eMOcK1C0TSu{O1V-d%kK_x5$U2KvpH>{dHIf9BhZT5GRYim+y^(!O0 z$OSfJodiL5L#u8}fbV|GbQm!|uhub2hqaB4y7z5vgPhp%E^?%8pAka)i*9Xvrf_*P9x^t#8p<7Qe-?M$Rfp^?p6#jw{MJo(|gH#&61| zbE_F-1iCfRJ8t!;w^{6K^4j6jxnolfVW#w1XRzpHzW)2FDw~UYG$N9KskF>PrFcUGUaw zdqLX)ig55s;|9$p1$gKy--MTP%>AtY^rN+yISknXix2FSAeLqX{+<-(h83k~p6T@* z36>*Ql-vH~c2Is{4nIJHdD~Mc*%>elEnMfGiQylz6$!dqa-7nHn0O)SD^nE>{joH@ zLx*UUFB&p{o_aW+FbazlODoe$0n_xau?wq53huvK$@+X^BgePPo*k4W`#7B5*t>gL zoz++a(r~voJ(w;ZeG?6j6uKG~M)2h57|eYu2r?d7&6Rh4s4NY6toVcB7mlnPrkO+FBrAQv2e&!c7;h?(N$O>L$#dS* zXA_`OqA(|8L{;bXmr4!viTJ_Nt3D%QZEj|g<$Qb4`B?(iW$)`0_%=`OJ-Yq+zD3Sw zBZ+oysoPV1+C?KleYjN(6VZOz#|dREnlx#u_oEE&;3TJgfR_f37lYKC@@Q=(n>#0k z+Vi_pm+Nq!S+mXuFAdQ*mBPF(ZvKr56~G4r=>Ey&02XRRS+P$uy#j(RW-DtU0Jka# zIC(ry4Cv3->YhN99m-*HLm`g%YFl}!q+wd5K8=RO{IoP3|A#2^GH`H zRxcwVQ~h0If=lO?-+Wi$>NIrGY5d6t6f%Knrcq^9=CmTWbhLq2@nfpQHU-CAjaUMNC)%!44+hzN8_!!3J@3@I^@8b||cu8A2wq;oxM|l7(c*G!P>R zd&B}m$nq>^AGn=4F_(4vplELQd#LZO&ByDDakui*)H7V%xGDow&rOu*h`nbLH*^&m z?B;^cRo-jdlYTQj-RNrNuT?O(qSIHJ)KK$+k-5LT-Ubq_R9JywOH z^B9(kye^+zPMQ&kb`0er2$4AnBmk5SAp1*0K$gBdqJC$;=U0Wm!oO<>Lo7PSubwnN zqnmIrK}J&;u*9072Hm~Tozzj3Y4Sh*!jW}A35qE=@QI8g%gFMfan`!>`PTxlJ1$wi zn2&m^H2bRG+^-7=y0x7QuidZR!$eI;XxEs;-M@@!d+}@L_b2Vo+DC|>VMn}btGuJL&UkpF)Y%Gi;N>mjG!PR_{Dn&?NeIo&`U^ZqN3_h#0fYGx=5}S5f@ihTnFcmc2WUH*UK;W|4R&;3A*2l_!eI`1OF1-On&B+9Z&T z`Kp*gjMko8K$2+)%j%fVl~1hK=oZmQCdPQF%P3ELXCb-fzE_XR}hkTIPFtZ|+A*xQDVp1=jsZt)Tt3#o`Q~%g(nQ z3XjDy+q!9d8Db!Q%rnrokpu9g73+fnwuV;&fl>%@dkHWHUpd63><^aj2Y}sYc4P98=(9e zaAwh}f8T%p;dFmX3P7fiygbd$elK~MOUC{m4gE=bkID%lt8~GWa3nK8u@kl z7p|H6VJ#m;e3`NLu7^lGGg=cRE=VHdLQFHPrVQqVs>;Sk5pv=1K88KJ!$e*_KR;G4 z+(nw|Q~n&mZ-i$POR2Kgy;Pt7K1e)}o8cd0J3$ksg}cH^d?NT8wVzg)3#}DB&S2QJ zjgWXgU9Vei(44DAwxguA&RK7#F?8eK#RK*A^M8+j*{(mUnr1d1Guz^Ct+f z+W685f_b>9AbV8HUpNAWad5^{^O>LM`|NII)*J-qZ)fJ_=*~Cp^Oh`429s2Jpd#~js;?^K6^a!sQ|#oAEr?@#{dOn0 zFG|zTz-+)n>0ZUIP~N=7o%Z=DObsTs04UUN+}>>Z$&Yl?;u4(vfx?00V0{qAk~T!5 zvr@}8Rq`Va!fz2DH!}A#EYo7 zGFM;+0P?1sO9V0+E*sr+%J|JHplg02n&wTF+wUz8noHh&#joG5vd_dIauxBiQs4^} zSWFx56X{6NfQf#QLZdT3RaksS`GFb{pWM+8ipz?I6s>r(&R24a3swXE6ld=Sq_WyE z&270RwimMWx*@4D2W*FkD87u8C8=zmEz}VxyNCXPl<{U;KoQ?&c0o*9Ph(N4Ommv6 z;cViWRWd8~%I~>~>guD}1me3aw@(}Y{5_D!)Y-!Hc!77v>H`V(tNe8YW?h0gu^1st zSGeJ!%blO6wfl( zKS4bEva@@Wy3W6Hq_>k+EA!2>=3+hBYJnl)O#>@Ol#dvkyx-BjXFIOEvhnq$)?d@KVCc&BnT7xZ2r|SCFuF)En~fVZ^<8;I4&MG@ zetYA*_ubkcNd2mGI-?K+5-HAFqWU8?pPM#_Z&IN?-o<&cdbsrSh6o$-!CUb*#!z0g zxU*?ftXk<3a{G%y>!a$T`vSz_A@@IhX#Pn$zM#G_x9WithDGtNtEc-nwnqkD1?-q1 zFg4o#c&hlrs#g6wqq=vw#`X~b?- zBw??s--FEN95)>br7cJy;^O#CVTRXc0%CMWyBziCvDQf*ci`0*Vsja;s4^3U`ei>x7r2nc3tvgBaCz-QQT2LW_b(Ro8W zbYq!t7>)@-g0&M@f`NLm%nBQ#TgG08#2-NK0F-2^=tducjUbd{j46ko#vWIYc_E3W zsr*X3x+>iUo~++&%5*x&@(NTX<3CYj=4SV1kvcoIa`BG7=Fa7g@;cWx3^-Ka`{e0; zMPi=!cvmzi@s1|HZ%F{ZwNBs5r!}dwDn2gV;&Iu2WvdxiE`eY2%4P8F)S~x*z zKtU8ilP}l0_^VMcpw{_Af1NE}$z262%t2DVw_gAA8Z?UCvpNeXNrJ_9*9T zsVEBv0au64iq=vx*!n;n`|R`Cgh#Ac?ijFC2tDB$A&j2Z0GaM*HaL*O8FBRor9`Yd zUf!i9pc~L2yu3>889k+&CI3A4wqES#Yz}lOC!6-46B)FM0@^oaWh~O1VG8=GoOct^ z_i_BD&;ASOJMrQqVR6h}xxYuzll+Br#thNgwFo;bWM;2bniOS3ysoI7W7=6 zSBvL%o-KGt*@#p7pgZ8}C|g_t(Y69hwBohpzdl4YL%v1S0vE4%thr#OicV^SsL68|J1s2NeNB);5KXl$0Dib|fwxlEgt!TyFoNj#ZC7yUI# z^2s_I_#!n!4-{XkoP3>(1C^IWHuhf7pT=Bm@nbTAI)tDt}RGgqYvP`<< zfdo&{OgYsM&2WE`>dZO`31w^uIF~c35X{&Y==8%v2U^0HZbyf~Nnh1QeNIDV@Y_Rj z=_l}>=5^5?^I2Czhxv38tNDug$+d>HQJ>zT(&wqt98~#|{@kkY0^Qo|s00Cw(ZI#l z5Jt_aCWneKYYz{rG1`iBQzbK9`b-TbyN{OLB#oJ zFOQqj(5dq}xs|o{VUT!II?$2T8-Ae2W2DX4BTA(-S}M($dfT>&@pH6f24g%a_N$>7 zT=g<7$&&~7G|EjyoR^M8!J^8cf)dBjnQd!O=fDHy#(kadW&_9PxoZ%@$kGtSBT6Mm zBvu0y$4o|(7jZelhuY9l>K&DvN`gP5<>t7&9IjwDa^bSy%5(3>hh{as{m==O{y$o| M|D)yo|1#*`03iv8i~s-t literal 0 HcmV?d00001 diff --git a/apps/mobile/index.js b/apps/mobile/index.js new file mode 100644 index 000000000..80d3d998f --- /dev/null +++ b/apps/mobile/index.js @@ -0,0 +1 @@ +import "expo-router/entry"; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2652f9df1..233501cdb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,7 +1,7 @@ { "name": "@posthog/mobile", "version": "1.0.0", - "main": "expo-router/entry", + "main": "./index.js", "scripts": { "start": "expo start", "start:clear": "expo start --clear", @@ -42,6 +42,7 @@ "expo-localization": "~17.0.8", "expo-router": "~6.0.17", "expo-secure-store": "^15.0.8", + "expo-speech-recognition": "^3.1.2", "expo-splash-screen": "~31.0.12", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 9641fa233..1e4bc6033 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,9 +1,11 @@ import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; import { DynamicColorIOS, Platform } from "react-native"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { useThemeColors } from "@/lib/theme"; export default function TabsLayout() { const themeColors = useThemeColors(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); // Dynamic colors for liquid glass effect on iOS const dynamicTextColor = @@ -30,21 +32,23 @@ export default function TabsLayout() { tintColor={dynamicTintColor} minimizeBehavior="onScrollDown" > - {/* Conversations - First Tab (default landing) */} - - - - + {/* Conversations - Chats tab, hidden by default to focus on Code */} + {aiChatEnabled && ( + + + + + )} - {/* Tasks Tab */} + {/* Code tab (task list for PostHog Code) */} - + s.aiChatEnabled); + + if (!aiChatEnabled) { + return ; + } const handleConversationPress = (conversation: ConversationDetail) => { router.push(`/chat/${conversation.id}`); diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 5c3907b3f..df35d9eb4 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -2,15 +2,21 @@ import { router } from "expo-router"; import { Linking, ScrollView, + Switch, Text, TouchableOpacity, View, } from "react-native"; import { useAuthStore, useUserQuery } from "@/features/auth"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; export default function SettingsScreen() { const { logout, cloudRegion, getCloudUrlFromRegion } = useAuthStore(); const { data: userData } = useUserQuery(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); + const setAiChatEnabled = usePreferencesStore((s) => s.setAiChatEnabled); + const pingsEnabled = usePreferencesStore((s) => s.pingsEnabled); + const setPingsEnabled = usePreferencesStore((s) => s.setPingsEnabled); const handleLogout = async () => { await logout(); @@ -88,6 +94,36 @@ export default function SettingsScreen() { + {/* Labs */} + + Labs + + Experimental features + + + + + PostHog AI chat + + + Show the Chats tab for PostHog AI conversations + + + + + + + + Enable pings + + + Play a sound when a task completes + + + + + + {/* All Settings Button */} { + const handle = InteractionManager.runAfterInteractions(() => { + readyRef.current = true; + }); + return () => { + readyRef.current = false; + handle.cancel(); + }; + }, []), + ); const handleCreateTask = () => { router.push("/task"); }; - const handleTaskPress = (taskId: string) => { - router.push(`/task/${taskId}`); - }; + const handleTaskPress = useCallback( + (taskId: string) => { + if (!readyRef.current) return; + readyRef.current = false; + router.push(`/task/${taskId}`); + }, + [router], + ); return ( @@ -20,8 +43,10 @@ export default function TasksScreen() { - Tasks - Your PostHog tasks + Code + + Your PostHog Code sessions + s.aiChatEnabled); const themeColors = useThemeColors(); useScreenTracking(); @@ -47,25 +49,29 @@ function RootLayoutNav() { - {/* Chat routes - regular stack navigation */} - - + {/* Chat routes - only registered when AI chat feature is enabled */} + {aiChatEnabled && ( + <> + + + + )} {/* Task routes - modal presentation */} ("us"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [devToken, setDevToken] = useState(""); + const [devProjectId, setDevProjectId] = useState(""); - const { loginWithOAuth } = useAuthStore(); + const { loginWithOAuth, loginWithPersonalApiKey } = useAuthStore(); + + const handleDevSignIn = async () => { + setIsLoading(true); + setError(null); + try { + const projectIdNum = Number(devProjectId); + if (!Number.isFinite(projectIdNum) || projectIdNum <= 0) { + throw new Error("Project ID must be a positive number"); + } + await loginWithPersonalApiKey({ + token: devToken, + projectId: projectIdNum, + region: selectedRegion, + }); + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to sign in"); + } finally { + setIsLoading(false); + } + }; const handleSignIn = async () => { setIsLoading(true); @@ -57,7 +87,11 @@ export default function AuthScreen() { return ( - + {/* Header */} @@ -131,8 +165,52 @@ export default function AuthScreen() { )} + + {__DEV__ && ( + + + Dev sign-in (personal API key) + + + Skips OAuth. Create a personal API key at Settings → User API + keys with scopes: user:read, project:read, task:write, + integration:read, conversation:write, query:read. + + + + + + Dev sign in + + + + )} - + ); } diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 35a1aa2d7..5372a88cb 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Composer } from "@/features/chat"; import { getTask, + runTaskInCloud, type Task, TaskSessionView, useTaskSessionStore, @@ -22,9 +23,15 @@ export default function TaskDetailScreen() { const [task, setTask] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(false); - const { connectToTask, disconnectFromTask, sendPrompt, getSessionForTask } = - useTaskSessionStore(); + const { + connectToTask, + disconnectFromTask, + sendPrompt, + sendPermissionResponse, + getSessionForTask, + } = useTaskSessionStore(); const session = taskId ? getSessionForTask(taskId) : undefined; @@ -47,27 +54,60 @@ export default function TaskDetailScreen() { useEffect(() => { if (!taskId) return; + let cancelled = false; setLoading(true); setError(null); getTask(taskId) .then((fetchedTask) => { + if (cancelled) return; setTask(fetchedTask); return connectToTask(fetchedTask); }) + .then(() => { + if (cancelled) return; + // Brief delay for FlatList to render its initial batch behind + // the loading overlay before revealing. + setTimeout(() => setLoading(false), 150); + }) .catch((err) => { + if (cancelled) return; console.error("Failed to load task:", err); setError("Failed to load task"); - }) - .finally(() => { setLoading(false); }); return () => { + cancelled = true; disconnectFromTask(taskId); }; }, [taskId, connectToTask, disconnectFromTask]); + // Auto-reconnect if the session disappears while the screen is active + // (e.g., cloud sandbox expired and the session was cleaned up). + // Re-fetches the task to get a fresh S3 presigned URL. + useEffect(() => { + if (!taskId || !task || loading) return; + if (session) return; + if (retrying) return; + + let cancelled = false; + getTask(taskId) + .then((freshTask) => { + if (cancelled) return; + setTask(freshTask); + return connectToTask(freshTask); + }) + .catch((err) => { + if (cancelled) return; + console.error("Failed to reconnect to task:", err); + }); + + return () => { + cancelled = true; + }; + }, [taskId, task, loading, session, connectToTask, retrying]); + const handleSendPrompt = useCallback( (text: string) => { if (!taskId) return; @@ -78,35 +118,51 @@ export default function TaskDetailScreen() { [taskId, sendPrompt], ); + const handleRetry = useCallback(async () => { + if (!taskId || !task) return; + try { + setRetrying(true); + disconnectFromTask(taskId); + + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + }); + setTask(updatedTask); + await connectToTask(updatedTask); + // Don't clear retrying here — the effect below clears it + // once the session shows meaningful state (thinking or terminal). + } catch (err) { + console.error("Failed to retry task:", err); + setRetrying(false); + } + }, [taskId, task, disconnectFromTask, connectToTask]); + + // Clear retrying once the agent finishes a turn or the run terminates. + useEffect(() => { + if (!retrying || !session) return; + if (!session.isPromptPending || session.terminalStatus) { + setRetrying(false); + } + }, [retrying, session]); + + const handleSendPermissionResponse = useCallback( + (args: Parameters[1]) => { + if (!taskId) return; + sendPermissionResponse(taskId, args).catch((err) => { + console.error("Failed to send permission response:", err); + }); + }, + [taskId, sendPermissionResponse], + ); + const handleOpenTask = useCallback( (newTaskId: string) => { - router.push(`/task/${newTaskId}`); + router.replace(`/task/${newTaskId}`); }, [router], ); - if (loading) { - return ( - <> - - - - Loading task... - - - ); - } - - if (error || !task) { + if (error || (!task && !loading)) { return ( <> ( + + + {environment === "cloud" ? "Cloud" : "Local"} + + + ) + : undefined, }} /> + {/* Always render TaskSessionView so the FlatList can layout behind + the loading overlay. This prevents the "flash of messages" when + switching from loading spinner to rendered content. */} 0 + } + terminalStatus={retrying ? undefined : session?.terminalStatus} + lastError={retrying ? undefined : session?.lastError} + onRetry={ + !retrying && session?.terminalStatus ? handleRetry : undefined + } onOpenTask={handleOpenTask} + onSendPermissionResponse={handleSendPermissionResponse} contentContainerStyle={{ - paddingTop: 80 + insets.bottom, + paddingTop: + session?.terminalStatus && !retrying ? 16 : 80 + insets.bottom, paddingBottom: 16, }} /> - {/* Fixed input at bottom */} - - - + {/* Loading overlay — covers the list while it does initial layout */} + {loading && ( + + + + {task?.latest_run ? "Connecting..." : "Loading task..."} + + + )} + + {/* Fixed input at bottom — hidden when run is terminal */} + {!session?.terminalStatus && ( + + + + )} ); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 42e535e15..1f4aaf2db 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; import { Stack, useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -79,10 +79,17 @@ export default function NewTaskScreen() { const [integrations, setIntegrations] = useState([]); const [repositories, setRepositories] = useState([]); const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearch, setRepoSearch] = useState(""); const [prompt, setPrompt] = useState(""); const [creating, setCreating] = useState(false); const [loadingRepos, setLoadingRepos] = useState(true); + const filteredRepositories = useMemo(() => { + const query = repoSearch.trim().toLowerCase(); + if (!query) return repositories; + return repositories.filter((repo) => repo.toLowerCase().includes(query)); + }, [repositories, repoSearch]); + const loadIntegrations = useCallback(async () => { try { setLoadingRepos(true); @@ -116,14 +123,21 @@ export default function NewTaskScreen() { try { const githubIntegration = integrations.find((i) => i.kind === "github"); + const trimmedPrompt = prompt.trim(); const task = await createTask({ - description: prompt.trim(), - title: prompt.trim().slice(0, 100), + description: trimmedPrompt, + title: trimmedPrompt.slice(0, 100), repository: selectedRepo, github_integration: githubIntegration?.id, }); - await runTaskInCloud(task.id); + // Pass the prompt as pending_user_message so the cloud agent has + // something to process on start — matches how the desktop launches + // new cloud runs. Without this the sandbox starts idle and the UI + // stays stuck on "Thinking...". + await runTaskInCloud(task.id, { + pendingUserMessage: trimmedPrompt, + }); // Navigate to task detail (replaces current modal) router.replace(`/task/${task.id}`); @@ -161,29 +175,50 @@ export default function NewTaskScreen() { ) : ( <> Repository + - item} - renderItem={({ item }) => ( - setSelectedRepo(item)} - className={`border-gray-6 border-b px-3 py-3 ${ - selectedRepo === item ? "bg-accent-3" : "" - }`} - > - + + {repoSearch + ? `No repositories match "${repoSearch}"` + : "No repositories available"} + + + ) : ( + item} + keyboardShouldPersistTaps="handled" + renderItem={({ item }) => ( + setSelectedRepo(item)} + className={`border-gray-6 border-b px-3 py-3 ${ + selectedRepo === item ? "bg-accent-3" : "" }`} > - {item} - - - )} - /> + + {item} + + + )} + /> + )} Task description diff --git a/apps/mobile/src/features/auth/stores/authStore.ts b/apps/mobile/src/features/auth/stores/authStore.ts index a9d105dbe..79a029415 100644 --- a/apps/mobile/src/features/auth/stores/authStore.ts +++ b/apps/mobile/src/features/auth/stores/authStore.ts @@ -29,6 +29,11 @@ interface AuthState { // Methods loginWithOAuth: (region: CloudRegion) => Promise; + loginWithPersonalApiKey: (params: { + token: string; + projectId: number; + region: CloudRegion; + }) => Promise; refreshAccessToken: () => Promise; scheduleTokenRefresh: () => void; initializeAuth: () => Promise; @@ -96,6 +101,40 @@ export const useAuthStore = create()( get().scheduleTokenRefresh(); }, + loginWithPersonalApiKey: async ({ token, projectId, region }) => { + if (!__DEV__) { + throw new Error( + "Dev sign-in is only available in development builds", + ); + } + const trimmed = token.trim(); + if (!trimmed) { + throw new Error("Personal API key is required"); + } + if (!Number.isFinite(projectId) || projectId <= 0) { + throw new Error("Valid project ID is required"); + } + + const storedTokens: StoredTokens = { + accessToken: trimmed, + refreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudRegion: region, + scopedTeams: [projectId], + }; + + await saveTokens(storedTokens); + + set({ + oauthAccessToken: trimmed, + oauthRefreshToken: null, + tokenExpiry: null, + cloudRegion: region, + projectId, + isAuthenticated: true, + }); + }, + refreshAccessToken: async () => { const state = get(); @@ -140,7 +179,8 @@ export const useAuthStore = create()( refreshTimeoutId = null; } - if (!state.tokenExpiry) { + // Personal API key sessions have no refresh token — nothing to schedule. + if (!state.tokenExpiry || !state.oauthRefreshToken) { return; } diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx index 96f3f1bc8..8a5e86042 100644 --- a/apps/mobile/src/features/chat/components/AgentMessage.tsx +++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx @@ -5,6 +5,7 @@ import { useThemeColors } from "@/lib/theme"; import { usePeriodicRerender } from "../hooks/usePeriodicRerender"; import type { AssistantToolCall } from "../types"; import { getRandomThinkingMessage } from "../utils/thinkingMessages"; +import { MarkdownText } from "./MarkdownText"; import { ToolMessage } from "./ToolMessage"; interface AgentMessageProps { @@ -107,9 +108,7 @@ export function AgentMessage({ {/* Show final content */} {content && ( - - {content} - + )} diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index d7f5bb9ef..0a1104389 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -1,8 +1,10 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; import { ArrowUp, Microphone, Stop } from "phosphor-react-native"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, + Animated, + Easing, Platform, TextInput, TouchableOpacity, @@ -15,12 +17,76 @@ interface ComposerProps { onSend: (message: string) => void; disabled?: boolean; placeholder?: string; + isUserTurn?: boolean; + queuedCount?: number; +} + +function PulsingBorder({ + active, + color, +}: { + active: boolean; + color: string; +}) { + const opacity = useRef(new Animated.Value(0)).current; + const animRef = useRef(null); + + useEffect(() => { + if (active) { + opacity.setValue(0); + animRef.current = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]), + ); + animRef.current.start(); + } else { + animRef.current?.stop(); + animRef.current = null; + opacity.setValue(0); + } + return () => { + animRef.current?.stop(); + }; + }, [active, opacity]); + + if (!active) return null; + + return ( + + ); } export function Composer({ onSend, disabled = false, placeholder = "Ask a question", + isUserTurn = false, + queuedCount = 0, }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); @@ -55,6 +121,11 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; + const effectivePlaceholder = queuedCount > 0 + ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` + : !isUserTurn && !disabled + ? "Message will be queued..." + : placeholder; if (Platform.OS === "ios") { return ( @@ -85,40 +156,46 @@ export function Composer({ gap: 8, }} > - {/* Input field with rounded glass background */} - - + + - + isInteractive + > + + + {/* Mic / Send button */} cell.trim()); + // Skip the separator row + if (!/^[\s-:|]+$/.test(lines[i].replace(/\|/g, ""))) { + rows.push(row); + } + i++; + } + if (rows.length > 0) { + blocks.push({ type: "table", content: "", rows }); + } + continue; + } + + // Empty line + if (line.trim() === "") { + i++; + continue; + } + + // Paragraph: collect consecutive non-special lines + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() !== "" && + !lines[i].startsWith("```") && + !lines[i].match(/^#{1,6}\s/) && + !/^\s*[-*]\s/.test(lines[i]) && + !/^\s*\d+[.)]\s/.test(lines[i]) && + !( + lines[i].includes("|") && + i + 1 < lines.length && + /^\s*\|?[\s-:|]+\|/.test(lines[i + 1]) + ) + ) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length > 0) { + blocks.push({ type: "paragraph", content: paraLines.join("\n") }); + } + } + + return blocks; +} + +function renderInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; + let lastIndex = 0; + let match: RegExpExecArray | null = null; + + // biome-ignore lint/suspicious/noAssignInExpressions: regex exec loop + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + if (match[2]) { + nodes.push( + + {match[2]} + , + ); + } else if (match[3]) { + nodes.push( + + {match[3]} + , + ); + } else if (match[4]) { + nodes.push( + + {match[4]} + , + ); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +export function MarkdownText({ content }: MarkdownTextProps) { + const blocks = parseBlocks(content); + + return ( + + {blocks.map((block, i) => { + const key = `block-${i}`; + + switch (block.type) { + case "code": + return ( + + + {block.content} + + + ); + + case "heading": + return ( + + {renderInline(block.content)} + + ); + + case "list": + return ( + + {block.items?.map((item, idx) => ( + + + {block.ordered ? `${idx + 1}.` : "•"} + + + {renderInline(item)} + + + ))} + + ); + + case "table": { + const rows = block.rows ?? []; + const header = rows[0]; + const body = rows.slice(1); + return ( + + + {header && ( + + {header.map((cell, col) => { + const colKey = `${key}-h${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} + + )} + {body.map((row, ri) => { + const rowKey = `${key}-r${ri}`; + return ( + + {row.map((cell, col) => { + const cellKey = `${rowKey}-c${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} + + ); + })} + + + ); + } + + default: + return ( + + {renderInline(block.content)} + + ); + } + })} + + ); +} diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index ee6085fcd..a5ab7f8a8 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -54,6 +54,81 @@ const kindIcons: Record = { other: Wrench, }; +export function deriveToolKind(toolName: string): ToolKind { + // Agent titles can include file paths, e.g. "Edit `src/foo.ts`" or + // "Read 200 lines in `bar.ts`", so match on prefix / keyword. + const name = toolName.toLowerCase(); + if (name.startsWith("read") || name === "read_file") return "read"; + if ( + name.startsWith("edit") || + name.startsWith("write") || + name.startsWith("multiedit") || + name.startsWith("multi_edit") || + name === "search_replace" + ) + return "edit"; + if (name.startsWith("delete")) return "delete"; + if ( + name.startsWith("grep") || + name.startsWith("search") || + name.startsWith("glob") || + name.startsWith("find") || + name.startsWith("list") + ) + return "search"; + if ( + name.startsWith("bash") || + name.startsWith("execute") || + name.startsWith("terminal") + ) + return "execute"; + if (name.startsWith("think")) return "think"; + if (name.startsWith("webfetch") || name.startsWith("fetch")) return "fetch"; + if (name === "create_task") return "create_task"; + return "other"; +} + +export function getToolSubtitle( + toolName: string, + args?: Record, +): string | null { + if (!args) return null; + const kind = deriveToolKind(toolName); + + switch (kind) { + case "read": + case "edit": + case "delete": + case "move": + if (typeof args.file_path === "string") + return shortenPath(args.file_path); + if (typeof args.target_file === "string") + return shortenPath(args.target_file); + return null; + case "search": + if (typeof args.pattern === "string") return `"${args.pattern}"`; + return null; + case "execute": + if (typeof args.command === "string") + return args.command.length > 60 + ? `${args.command.slice(0, 60)}...` + : args.command; + return null; + case "fetch": + if (typeof args.url === "string") + return args.url.length > 60 ? `${args.url.slice(0, 60)}...` : args.url; + return null; + case "think": + if (typeof args.content === "string") + return args.content.length > 60 + ? `${args.content.slice(0, 60)}...` + : args.content; + return null; + default: + return null; + } +} + interface CreateTaskArgs { title?: string; description?: string; @@ -93,6 +168,403 @@ export function formatToolTitle( return toolName; } +// Shape guards for file-editing tool args. The agent forwards Claude's raw +// tool input through the ACP `rawInput` field, so we can detect Edit / Write / +// MultiEdit by the keys present in args. +interface EditArgs { + file_path: string; + old_string: string; + new_string: string; +} + +interface MultiEditArgs { + file_path: string; + edits: Array<{ old_string: string; new_string: string }>; +} + +interface WriteArgs { + file_path: string; + content: string; +} + +function asEditArgs( + args: Record | undefined, +): EditArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.old_string === "string" && + typeof args.new_string === "string" + ) { + return { + file_path: args.file_path, + old_string: args.old_string, + new_string: args.new_string, + }; + } + return null; +} + +function asMultiEditArgs( + args: Record | undefined, +): MultiEditArgs | null { + if (!args || typeof args.file_path !== "string") return null; + if (!Array.isArray(args.edits)) return null; + const edits: MultiEditArgs["edits"] = []; + for (const raw of args.edits) { + if ( + raw && + typeof raw === "object" && + typeof (raw as Record).old_string === "string" && + typeof (raw as Record).new_string === "string" + ) { + edits.push({ + old_string: (raw as Record).old_string as string, + new_string: (raw as Record).new_string as string, + }); + } + } + if (edits.length === 0) return null; + return { file_path: args.file_path, edits }; +} + +function asWriteArgs( + args: Record | undefined, +): WriteArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.content === "string" && + args.old_string === undefined + ) { + return { file_path: args.file_path, content: args.content }; + } + return null; +} + +// Strip ANSI escape codes from terminal output +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI codes requires matching control chars + return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); +} + +function extractResultText(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + if (typeof obj[key] === "string") return obj[key] as string; + } + } + return null; +} + +function countDiffLines( + editArgs: EditArgs | null, + multiEditArgs: MultiEditArgs | null, + writeArgs: WriteArgs | null, +): { added: number; removed: number } { + let added = 0; + let removed = 0; + + const countFromDiff = (oldText: string, newText: string) => { + const lines = computeLineDiff(oldText, newText, Number.MAX_SAFE_INTEGER); + for (const line of lines) { + if (line.kind === "added") added++; + else if (line.kind === "removed") removed++; + } + }; + + if (editArgs) { + countFromDiff(editArgs.old_string ?? "", editArgs.new_string ?? ""); + } else if (multiEditArgs) { + for (const edit of multiEditArgs.edits) { + countFromDiff(edit.old_string ?? "", edit.new_string ?? ""); + } + } else if (writeArgs) { + added = writeArgs.content ? writeArgs.content.split("\n").length : 0; + } + + return { added, removed }; +} + +// Extract a file path from agent tool titles like "Read `src/foo.ts`" or +// "Read 200 lines in `bar.ts`" when rawInput/args are unavailable. +function extractPathFromTitle(title: string): string | null { + const backtickMatch = title.match(/`([^`]+)`/); + if (backtickMatch) return backtickMatch[1]; + // Fallback: strip common prefixes like "Read file", "Read 200 lines in" + const stripped = title + .replace(/^read\s+/i, "") + .replace(/^file\s*/i, "") + .replace(/^\d+\s+lines?\s+in\s+/i, "") + .trim(); + // Only treat the remainder as a path if it looks like one + if (stripped.includes("/") || stripped.includes(".")) return stripped; + return null; +} + +function shortenPath(path: string, maxLen = 48): string { + if (path.length <= maxLen) return path; + const parts = path.split("/"); + if (parts.length <= 2) return `…${path.slice(-(maxLen - 1))}`; + return `…/${parts.slice(-2).join("/")}`; +} + +// Unified diff support — detects and renders `git diff` output when the agent +// runs commands like `git diff` through the Bash tool and the result comes +// back as stdout rather than a structured tool content block. +type UnifiedDiffLine = + | { kind: "file"; text: string } + | { kind: "hunk"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "context"; text: string } + | { kind: "meta"; text: string }; + +function looksLikeUnifiedDiff(text: string): boolean { + if (!text) return false; + if (/(^|\n)diff --git /.test(text)) return true; + return /(^|\n)--- /.test(text) && /(^|\n)\+\+\+ /.test(text); +} + +function extractDiffFromResult(result: unknown): string | null { + if (typeof result === "string") { + return looksLikeUnifiedDiff(result) ? result : null; + } + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + const value = obj[key]; + if (typeof value === "string" && looksLikeUnifiedDiff(value)) { + return value; + } + } + } + return null; +} + +function parseUnifiedDiff(text: string): UnifiedDiffLine[] { + const result: UnifiedDiffLine[] = []; + for (const line of text.split("\n")) { + if ( + line.startsWith("diff --git ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("index ") || + line.startsWith("new file mode") || + line.startsWith("deleted file mode") || + line.startsWith("similarity index") || + line.startsWith("rename ") + ) { + result.push({ kind: "file", text: line }); + } else if (line.startsWith("@@")) { + result.push({ kind: "hunk", text: line }); + } else if (line.startsWith("+")) { + result.push({ kind: "added", text: line }); + } else if (line.startsWith("-")) { + result.push({ kind: "removed", text: line }); + } else if (line.startsWith(" ")) { + result.push({ kind: "context", text: line }); + } else { + result.push({ kind: "meta", text: line }); + } + } + return result; +} + +interface UnifiedDiffBlockProps { + diffText: string; + maxLines?: number; +} + +function UnifiedDiffBlock({ diffText, maxLines = 120 }: UnifiedDiffBlockProps) { + const allLines = parseUnifiedDiff(diffText); + const truncated = allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + let cls = "font-mono text-[11px] leading-4 text-gray-11 px-2"; + if (line.kind === "file") { + cls += " text-gray-9"; + } else if (line.kind === "hunk") { + cls += " bg-accent-3 text-accent-11"; + } else if (line.kind === "added") { + cls += " bg-status-success/10 text-status-success"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10 text-status-error"; + } else if (line.kind === "context") { + cls += " text-gray-11"; + } else { + cls += " text-gray-9"; + } + return ( + + {line.text || " "} + + ); + })} + {truncated && ( + + … {allLines.length - maxLines} more lines + + )} + + ); +} + +// LCS-based line diff: correctly identifies unchanged lines even when +// changes are scattered throughout the block, then collapses distant +// context into separators. +type DiffLine = + | { kind: "context"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "separator" }; + +// O(n*m) LCS — fine for typical edit blocks (< 200 lines). +function lcsBacktrack(a: string[], b: string[]): DiffLine[] { + const m = a.length; + const n = b.length; + + // Build LCS table + const dp: number[][] = []; + for (let i = 0; i <= m; i++) { + dp[i] = new Array(n + 1).fill(0); + } + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + // Backtrack to produce diff + const result: DiffLine[] = []; + let i = m; + let j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + result.push({ kind: "context", text: a[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.push({ kind: "added", text: b[j - 1] }); + j--; + } else { + result.push({ kind: "removed", text: a[i - 1] }); + i--; + } + } + result.reverse(); + return result; +} + +// Collapse context lines far from changes into separators. +function collapseContext(lines: DiffLine[], contextLines: number): DiffLine[] { + // Mark which lines are near a change + const isChange = lines.map((l) => l.kind === "added" || l.kind === "removed"); + const nearChange = new Array(lines.length).fill(false); + for (let i = 0; i < lines.length; i++) { + if (isChange[i]) { + for ( + let k = Math.max(0, i - contextLines); + k <= Math.min(lines.length - 1, i + contextLines); + k++ + ) { + nearChange[k] = true; + } + } + } + + const result: DiffLine[] = []; + let inSkip = false; + for (let i = 0; i < lines.length; i++) { + if (nearChange[i] || isChange[i]) { + inSkip = false; + result.push(lines[i]); + } else if (!inSkip) { + inSkip = true; + result.push({ kind: "separator" }); + } + } + return result; +} + +function computeLineDiff( + oldText: string, + newText: string, + contextLines = 2, +): DiffLine[] { + const oldLines = oldText.length > 0 ? oldText.split("\n") : []; + const newLines = newText.length > 0 ? newText.split("\n") : []; + + if (oldLines.length === 0) { + return newLines.map((l) => ({ kind: "added" as const, text: l })); + } + if (newLines.length === 0) { + return oldLines.map((l) => ({ kind: "removed" as const, text: l })); + } + + const raw = lcsBacktrack(oldLines, newLines); + return collapseContext(raw, contextLines); +} + +interface DiffBlockProps { + oldText: string; + newText: string; + maxLines?: number; +} + +function DiffBlock({ oldText, newText, maxLines = 60 }: DiffBlockProps) { + const allLines = computeLineDiff(oldText, newText); + const truncated = allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + const key = `${line.kind}-${i}`; + if (line.kind === "separator") { + return ( + + ··· + + ); + } + let cls = "font-mono text-[11px] leading-4 px-2"; + if (line.kind === "added") { + cls += " bg-status-success/10 text-status-success"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10 text-status-error"; + } else { + cls += " text-gray-11"; + } + const prefix = + line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "; + return ( + + {prefix} + {line.text || " "} + + ); + })} + {truncated && ( + + … {allLines.length - maxLines} more lines + + )} + + ); +} + function CreateTaskPreview({ args, showAction, @@ -234,13 +706,117 @@ export function ToolMessage({ const isLoading = status === "pending" || status === "running"; const isFailed = status === "error"; - const hasDetails = args || result !== undefined; const displayTitle = formatToolTitle(toolName, args); const KindIcon = kind ? kindIcons[kind] : Wrench; const isCreateTask = toolName.toLowerCase() === "create_task" || kind === "create_task"; + // File-editing tools get a proper diff view using the rawInput we already + // receive on the wire. Detection is by shape, not tool name, so it works + // regardless of how the agent labels the tool. + const editArgs = asEditArgs(args); + const multiEditArgs = !editArgs ? asMultiEditArgs(args) : null; + const writeArgs = !editArgs && !multiEditArgs ? asWriteArgs(args) : null; + const fileToolArgs = editArgs ?? multiEditArgs ?? writeArgs; + + // Unified-diff-in-result: when the agent runs commands like `git diff` + // via the Bash tool, the result comes back as stdout containing a unified + // diff string. Detect that and render it as a real diff view. + const unifiedDiffText = !fileToolArgs ? extractDiffFromResult(result) : null; + + if (fileToolArgs && !isCreateTask) { + const stats = countDiffLines(editArgs, multiEditArgs, writeArgs); + // Collapse diffs for failed edits (retries make them noise) + const showDiff = !isFailed || isOpen; + + return ( + + {/* Header row */} + isFailed && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!isFailed} + > + {isLoading ? ( + + ) : ( + + )} + + {shortenPath(fileToolArgs.file_path)} + + {stats.added > 0 && !isFailed && ( + + +{stats.added} + + )} + {stats.removed > 0 && !isFailed && ( + + -{stats.removed} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Diff content — collapsed when failed */} + {showDiff && ( + <> + {editArgs && ( + + )} + {multiEditArgs?.edits.map((edit, i) => ( + + ))} + {writeArgs && } + + )} + + ); + } + + // Unified-diff-in-result renderer (e.g. `git diff` via Bash) + if (unifiedDiffText && !isCreateTask) { + return ( + + + {isLoading ? ( + + ) : ( + + )} + + {displayTitle} + + {isFailed && ( + (Failed) + )} + + + + ); + } + // For create_task, show rich preview instead of expandable if (isCreateTask && args) { return ( @@ -264,18 +840,169 @@ export function ToolMessage({ ); } + const resolvedKind = kind ?? deriveToolKind(toolName); + const isPending = status === "pending"; + const isRunning = status === "running"; + const isCompleted = status === "completed"; + const resultText = extractResultText(result); + + // Execute/Bash: show description + command subtitle + expandable output + if (resolvedKind === "execute") { + const command = typeof args?.command === "string" ? args.command : null; + const description = + typeof args?.description === "string" ? args.description : null; + const outputText = resultText ? stripAnsi(resultText) : null; + const hasOutput = outputText && outputText.trim().length > 0; + + return ( + + {/* Header */} + hasOutput && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasOutput} + > + {isLoading ? ( + + ) : ( + + )} + + {description ?? displayTitle} + + {isFailed && ( + + Failed + + )} + + + {/* Command as subtitle line */} + {command && ( + + $ {command} + + )} + + {/* Output */} + {isOpen && hasOutput && ( + + + {outputText} + + + )} + + ); + } + + // Read: show file path, line range, and expandable content preview + if (resolvedKind === "read") { + // Try args first, then extract a path from the tool title (e.g. + // "Read `src/foo.ts`" or "Read 200 lines in `bar.ts`"). + const filePath = + typeof args?.file_path === "string" + ? args.file_path + : typeof args?.target_file === "string" + ? args.target_file + : extractPathFromTitle(toolName); + const hasContent = resultText && resultText.trim().length > 0; + const lineCount = hasContent ? resultText.split("\n").length : null; + const offset = typeof args?.offset === "number" ? args.offset : null; + const limit = typeof args?.limit === "number" ? args.limit : null; + const lineRange = offset + ? `lines ${offset}–${offset + (limit ?? lineCount ?? 0)}` + : lineCount + ? `${lineCount} lines` + : null; + + return ( + + hasContent && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasContent} + > + {isLoading ? ( + + ) : ( + + )} + + Read + + {filePath ? ( + + {shortenPath(filePath, 36)} + + ) : null} + {lineRange && isCompleted && ( + + {lineRange} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Content preview */} + {isOpen && hasContent && ( + + + {resultText} + + + )} + + ); + } + + // Default: all other tools (search, think, fetch, etc.) + const subtitle = getToolSubtitle(toolName, args); + return ( - - hasDetails && setIsOpen(!isOpen)} - className="flex-row items-center gap-2" - disabled={!hasDetails} - > + + {/* Status indicator */} {isLoading ? ( ) : ( - + )} {/* Tool name */} @@ -283,45 +1010,28 @@ export function ToolMessage({ {displayTitle} + {/* Queued label */} + {isPending && ( + Queued + )} + {/* Failed indicator */} {isFailed && ( - (Failed) + + Failed + )} - - - {/* Expanded content */} - {isOpen && hasDetails && ( - - {args && ( - - - Arguments - - - - {JSON.stringify(args, null, 2)} - - - - )} - {result !== undefined && ( - - - Result - - - - {typeof result === "string" - ? result - : JSON.stringify(result, null, 2)} - - - - )} - + + + {/* Contextual subtitle */} + {subtitle && !isPending && ( + + {subtitle} + )} ); diff --git a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts index 5683fbe39..e1302b142 100644 --- a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts +++ b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts @@ -1,7 +1,5 @@ -import { Audio } from "expo-av"; -import { File } from "expo-file-system"; +import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; import { useCallback, useRef, useState } from "react"; -import { useAuthStore } from "@/features/auth"; type RecordingStatus = "idle" | "recording" | "transcribing" | "error"; @@ -16,148 +14,128 @@ interface UseVoiceRecordingReturn { export function useVoiceRecording(): UseVoiceRecordingReturn { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); - const recordingRef = useRef(null); + const transcriptRef = useRef(""); + const resolveRef = useRef<((text: string | null) => void) | null>(null); + const listenersRef = useRef<(() => void)[]>([]); + + const cleanup = useCallback(() => { + for (const remove of listenersRef.current) { + remove(); + } + listenersRef.current = []; + resolveRef.current = null; + transcriptRef.current = ""; + }, []); const startRecording = useCallback(async () => { try { setError(null); + transcriptRef.current = ""; - // Request permissions - const { granted } = await Audio.requestPermissionsAsync(); - if (!granted) { - setError("Microphone permission is required"); + if (!ExpoSpeechRecognitionModule.isRecognitionAvailable()) { + setError("Speech recognition is not available on this device"); setStatus("error"); return; } - // Configure audio mode for recording - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - - // Create and start recording - const recording = new Audio.Recording(); - await recording.prepareToRecordAsync( - Audio.RecordingOptionsPresets.HIGH_QUALITY, - ); - await recording.startAsync(); - recordingRef.current = recording; - setStatus("recording"); - } catch (err) { - console.error("Failed to start recording:", err); - setError("Failed to start recording"); - setStatus("error"); - } - }, []); - - const stopRecording = useCallback(async (): Promise => { - if (!recordingRef.current) { - return null; - } - - try { - setStatus("transcribing"); - - // Stop recording and get URI - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - recordingRef.current = null; - - // Reset audio mode - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - }); - - if (!uri) { - setError("No recording found"); + const { granted } = + await ExpoSpeechRecognitionModule.requestPermissionsAsync(); + if (!granted) { + setError("Speech recognition permission is required"); setStatus("error"); - return null; + return; } - const { - oauthAccessToken, - cloudRegion, - projectId, - getCloudUrlFromRegion, - } = useAuthStore.getState(); - - if (!oauthAccessToken || !cloudRegion || !projectId) { - setError("Not authenticated"); - setStatus("error"); - return null; - } + // Listen for results — accumulate the latest transcript + const resultSub = ExpoSpeechRecognitionModule.addListener( + "result", + (event) => { + const best = event.results[0]?.transcript; + if (best) { + transcriptRef.current = best; + } + if (event.isFinal && resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }, + ); - const cloudUrl = getCloudUrlFromRegion(cloudRegion); - - // Create form data with the recording file - const formData = new FormData(); - formData.append("file", { - uri, - type: "audio/mp4", - name: "recording.m4a", - } as unknown as Blob); - - // Call PostHog LLM Gateway transcription API - const response = await fetch( - `${cloudUrl}/api/projects/${projectId}/llm_gateway/v1/audio/transcriptions`, - { - method: "POST", - headers: { - Authorization: `Bearer ${oauthAccessToken}`, - }, - body: formData, + const errorSub = ExpoSpeechRecognitionModule.addListener( + "error", + (event) => { + // "no-speech" is not a real error — just means the user didn't say anything + if (event.error === "no-speech") { + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("idle"); + return; + } + setError(event.message || "Speech recognition failed"); + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("error"); }, ); - // Clean up the temp file - const recordingFile = new File(uri); - if (recordingFile.exists) { - await recordingFile.delete(); - } + // If recognition ends without a final result (e.g. silence timeout) + const endSub = ExpoSpeechRecognitionModule.addListener("end", () => { + if (resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }); - if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Transcription failed: ${errorData}`); - } + listenersRef.current = [ + () => resultSub.remove(), + () => errorSub.remove(), + () => endSub.remove(), + ]; + + const useOnDevice = + ExpoSpeechRecognitionModule.supportsOnDeviceRecognition(); + + ExpoSpeechRecognitionModule.start({ + lang: "en-US", + interimResults: true, + requiresOnDeviceRecognition: useOnDevice, + addsPunctuation: true, + }); - const data = await response.json(); - setStatus("idle"); - return data.text; + setStatus("recording"); } catch (err) { - console.error("Failed to transcribe:", err); - const errorMessage = - err instanceof Error ? err.message : "Transcription failed"; - setError(errorMessage); + console.error("Failed to start speech recognition:", err); + setError("Failed to start speech recognition"); setStatus("error"); - return null; } - }, []); + }, [cleanup]); - const cancelRecording = useCallback(async () => { - if (recordingRef.current) { - try { - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - if (uri) { - const file = new File(uri); - if (file.exists) { - await file.delete(); - } - } - } catch { - // Ignore cleanup errors - } - recordingRef.current = null; + const stopRecording = useCallback(async (): Promise => { + if (status !== "recording") { + return null; } - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, + setStatus("transcribing"); + + return new Promise((resolve) => { + resolveRef.current = resolve; + // stop() asks the recognizer to deliver a final result then end + ExpoSpeechRecognitionModule.stop(); }); + }, [status]); + const cancelRecording = useCallback(async () => { + ExpoSpeechRecognitionModule.abort(); + cleanup(); setStatus("idle"); setError(null); - }, []); + }, [cleanup]); return { status, diff --git a/apps/mobile/src/features/chat/index.ts b/apps/mobile/src/features/chat/index.ts index 3ca5c7509..cc4d7c8d1 100644 --- a/apps/mobile/src/features/chat/index.ts +++ b/apps/mobile/src/features/chat/index.ts @@ -11,7 +11,7 @@ export type { ToolMessageProps, ToolStatus, } from "./components/ToolMessage"; -export { ToolMessage } from "./components/ToolMessage"; +export { deriveToolKind, ToolMessage } from "./components/ToolMessage"; export { VisualizationArtifact } from "./components/VisualizationArtifact"; // Hooks diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts new file mode 100644 index 000000000..1e44c1cce --- /dev/null +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -0,0 +1,29 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface PreferencesState { + aiChatEnabled: boolean; + setAiChatEnabled: (enabled: boolean) => void; + pingsEnabled: boolean; + setPingsEnabled: (enabled: boolean) => void; +} + +export const usePreferencesStore = create()( + persist( + (set) => ({ + aiChatEnabled: false, + setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), + pingsEnabled: true, + setPingsEnabled: (enabled) => set({ pingsEnabled: enabled }), + }), + { + name: "posthog-preferences", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + aiChatEnabled: state.aiChatEnabled, + pingsEnabled: state.pingsEnabled, + }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 10f023349..7e35ce882 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -160,16 +160,52 @@ export async function deleteTask(taskId: string): Promise { } } -export async function runTaskInCloud(taskId: string): Promise { +export interface RunTaskInCloudOptions { + branch?: string | null; + resumeFromRunId?: string; + pendingUserMessage?: string; + mode?: "interactive" | "background"; +} + +export async function runTaskInCloud( + taskId: string, + options?: RunTaskInCloudOptions, +): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); const headers = getHeaders(); + // Only serialize a body when we have options to send. Sending an empty + // or minimal body on the initial run historically changed backend + // behavior, so we preserve the "no body" path for the common case. + const hasOptions = + !!options && + (options.branch !== undefined || + options.resumeFromRunId !== undefined || + options.pendingUserMessage !== undefined || + options.mode !== undefined); + + let body: string | undefined; + if (hasOptions) { + const payload: Record = { + mode: options?.mode ?? "interactive", + }; + if (options?.branch) payload.branch = options.branch; + if (options?.resumeFromRunId) { + payload.resume_from_run_id = options.resumeFromRunId; + } + if (options?.pendingUserMessage) { + payload.pending_user_message = options.pendingUserMessage; + } + body = JSON.stringify(payload); + } + const response = await fetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`, { method: "POST", headers, + body, }, ); @@ -228,10 +264,113 @@ export async function appendTaskRunLog( ); } +/** + * Structured error thrown by `sendCloudCommand`. Exposes the HTTP status and + * the backend error payload so callers can branch on specific failure modes + * (e.g. "No active sandbox for this task run" → trigger a resume flow). + */ +export class CloudCommandError extends Error { + readonly status: number; + readonly backendError: string | null; + readonly method: string; + + constructor( + method: string, + status: number, + backendError: string | null, + message: string, + ) { + super(message); + this.name = "CloudCommandError"; + this.method = method; + this.status = status; + this.backendError = backendError; + } + + /** True when the cloud sandbox for this run has terminated. */ + isSandboxInactive(): boolean { + return ( + !!this.backendError?.includes("No active sandbox") || + !!this.backendError?.includes("returned 404") || + this.status === 404 + ); + } +} + +/** + * Sends a JSON-RPC command to a running cloud task. This is the correct path + * for delivering follow-up user prompts to the agent — it gets translated into + * `session/prompt` on the agent side. Note: `appendTaskRunLog` only writes to + * S3 for display; it does NOT notify the agent. + */ +export async function sendCloudCommand( + taskId: string, + runId: string, + method: string, + params: Record = {}, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const body = { + jsonrpc: "2.0", + method, + params, + id: `posthog-mobile-${Date.now()}`, + }; + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/command/`, + { + method: "POST", + headers, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + let backendError: string | null = null; + try { + const parsed = JSON.parse(text); + backendError = + typeof parsed?.error === "string" + ? parsed.error + : (parsed?.error?.message ?? null); + } catch { + backendError = text || null; + } + throw new CloudCommandError( + method, + response.status, + backendError, + `Cloud command '${method}' failed: ${response.status} ${response.statusText} ${text}`, + ); + } + + const data = await response.json(); + if (data?.error) { + const message = + typeof data.error === "string" + ? data.error + : (data.error.message ?? JSON.stringify(data.error)); + throw new CloudCommandError( + method, + 200, + message, + `Cloud command '${method}' error: ${message}`, + ); + } + return data?.result; +} + export async function fetchS3Logs(logUrl: string): Promise { return withRetry( async () => { - const response = await fetch(logUrl); + const response = await fetch(logUrl, { + signal: AbortSignal.timeout(10_000), + }); if (!response.ok) { if (response.status === 404) { @@ -281,17 +420,13 @@ export async function getGithubRepositories( } const data = await response.json(); - - const integrations = await getIntegrations(); - const integration = integrations.find((i) => i.id === integrationId); - const organization = - integration?.display_name || - integration?.config?.account?.login || - "unknown"; - - const repoNames = data.repositories ?? data.results ?? data ?? []; - return repoNames.map( - (repoName: string) => - `${organization.toLowerCase()}/${repoName.toLowerCase()}`, - ); + const repos: Array = + data.repositories ?? data.results ?? data ?? []; + + return repos + .map((repo) => { + if (typeof repo === "string") return repo.toLowerCase(); + return (repo.full_name ?? repo.name ?? "").toLowerCase(); + }) + .filter((name) => name.length > 0); } diff --git a/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx new file mode 100644 index 000000000..2c8633cc5 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx @@ -0,0 +1,88 @@ +import { CheckCircle, CircleDashed, XCircle } from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { PlanEntry } from "../types"; + +interface PlanStatusBarProps { + plan: PlanEntry[] | null; +} + +function StatusIcon({ status }: { status: string }) { + const themeColors = useThemeColors(); + + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + case "failed": + return ; + default: + return ; + } +} + +export function PlanStatusBar({ plan }: PlanStatusBarProps) { + const [isExpanded, setIsExpanded] = useState(false); + const themeColors = useThemeColors(); + + const stats = useMemo(() => { + if (!plan?.length) return null; + + const completed = plan.filter((e) => e.status === "completed").length; + const total = plan.length; + const inProgress = plan.find((e) => e.status === "in_progress"); + const allCompleted = completed === total; + + return { completed, total, inProgress, allCompleted }; + }, [plan]); + + if (!stats || stats.allCompleted) return null; + + return ( + + setIsExpanded(!isExpanded)} + className="flex-row items-center gap-2 px-4 py-2.5" + > + + {stats.completed}/{stats.total} completed + + {stats.inProgress && ( + <> + · + + + {stats.inProgress.content} + + + )} + + + {isExpanded && plan && ( + + {plan.map((entry) => ( + + + + {entry.content} + + + ))} + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/QuestionCard.tsx b/apps/mobile/src/features/tasks/components/QuestionCard.tsx new file mode 100644 index 000000000..6cca5c807 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/QuestionCard.tsx @@ -0,0 +1,378 @@ +import { + ChatCircle, + CheckCircle, + CircleDashed, + RadioButton, +} from "phosphor-react-native"; +import { useState } from "react"; +import { + Pressable, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import type { ToolStatus } from "@/features/chat"; +import { useThemeColors } from "@/lib/theme"; + +interface QuestionOption { + label: string; + description?: string; +} + +interface QuestionItem { + question: string; + header?: string; + options: QuestionOption[]; + multiSelect?: boolean; +} + +interface ToolData { + toolName: string; + toolCallId: string; + status: ToolStatus; + args?: Record; + result?: unknown; +} + +interface PermissionResponseArgs { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; +} + +interface QuestionCardProps { + toolData: ToolData; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; +} + +function extractQuestions(args?: Record): QuestionItem[] { + if (!args) return []; + // Questions may be at top level or nested under input + const raw = + args.questions ?? (args.input as Record)?.questions; + if (!Array.isArray(raw)) return []; + return raw.filter( + (q): q is QuestionItem => + q != null && + typeof q === "object" && + typeof (q as QuestionItem).question === "string" && + Array.isArray((q as QuestionItem).options), + ); +} + +function extractAnswer(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + if (typeof obj.answer === "string") return obj.answer; + if (typeof obj.answers === "object" && obj.answers) { + const answers = obj.answers as Record; + return Object.values(answers).join(", "); + } + if (typeof obj.text === "string") return obj.text; + if (typeof obj.content === "string") return obj.content; + } + return null; +} + +export function QuestionCard({ + toolData, + onSendPermissionResponse, +}: QuestionCardProps) { + const themeColors = useThemeColors(); + const questions = extractQuestions(toolData.args); + const isCompleted = + toolData.status === "completed" || toolData.status === "error"; + + if (questions.length === 0) { + return null; + } + + if (isCompleted) { + const answer = extractAnswer(toolData.result); + return ( + + + + + {questions[0]?.header ?? "Question"} + + + + + {questions[0]?.question} + + {answer && ( + + + + {answer} + + + )} + + + ); + } + + return ( + + ); +} + +function InteractiveQuestion({ + questions, + toolCallId, + onSendPermissionResponse, +}: { + questions: QuestionItem[]; + toolCallId: string; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; +}) { + const themeColors = useThemeColors(); + const [currentIndex, setCurrentIndex] = useState(0); + const [selectedOptions, setSelectedOptions] = useState< + Map> + >(new Map()); + const [otherTexts, setOtherTexts] = useState>(new Map()); + const [showOtherInput, setShowOtherInput] = useState>( + new Map(), + ); + + const question = questions[currentIndex]; + if (!question) return null; + + const isMultiSelect = question.multiSelect ?? false; + const isLastQuestion = currentIndex === questions.length - 1; + const selected = selectedOptions.get(currentIndex) ?? new Set(); + const otherText = otherTexts.get(currentIndex) ?? ""; + const isOtherShown = showOtherInput.get(currentIndex) ?? false; + const hasSelection = selected.size > 0 || otherText.trim().length > 0; + + const toggleOption = (label: string) => { + const newSelected = new Map(selectedOptions); + const current = new Set(selected); + + if (isMultiSelect) { + if (current.has(label)) { + current.delete(label); + } else { + current.add(label); + } + } else { + if (current.has(label)) { + current.clear(); + } else { + current.clear(); + current.add(label); + } + // Clear "Other" when selecting a preset option + const newOther = new Map(showOtherInput); + newOther.set(currentIndex, false); + setShowOtherInput(newOther); + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, ""); + setOtherTexts(newTexts); + } + + newSelected.set(currentIndex, current); + setSelectedOptions(newSelected); + }; + + const toggleOther = () => { + const newOther = new Map(showOtherInput); + const isNowShown = !isOtherShown; + newOther.set(currentIndex, isNowShown); + setShowOtherInput(newOther); + + if (!isMultiSelect && isNowShown) { + // Clear preset selections when choosing "Other" in single-select + const newSelected = new Map(selectedOptions); + newSelected.set(currentIndex, new Set()); + setSelectedOptions(newSelected); + } + }; + + const handleSubmit = () => { + const parts: string[] = []; + for (const label of selected) { + parts.push(label); + } + const trimmedOther = otherText.trim(); + if (trimmedOther) { + parts.push(trimmedOther); + } + const answer = parts.join(", "); + + if (!isLastQuestion) { + setCurrentIndex(currentIndex + 1); + return; + } + + if (!answer || !onSendPermissionResponse) return; + + // Derive the ACP optionId the agent is expecting. Options are built + // server-side (buildQuestionOptions in packages/agent) as + // `${OPTION_PREFIX}${idx}` where OPTION_PREFIX is "option_". If the + // user only typed into "Other", fall back to option_0 — the answers + // map carries the actual content for the agent. + const firstSelectedLabel = parts[0]; + const selectedIdx = question.options.findIndex( + (o) => o.label === firstSelectedLabel, + ); + const optionIdx = selectedIdx >= 0 ? selectedIdx : 0; + const optionId = `option_${optionIdx}`; + + onSendPermissionResponse({ + toolCallId, + optionId, + answers: { [question.question]: answer }, + customInput: trimmedOther || undefined, + displayText: answer, + }); + }; + + return ( + + {/* Header */} + + + + {question.header ?? "Question"} + + {questions.length > 1 && ( + + {currentIndex + 1}/{questions.length} + + )} + + + {/* Question text */} + + + {question.question} + + + + {/* Options */} + + {question.options.map((option) => { + const isSelected = selected.has(option.label); + return ( + toggleOption(option.label)} + className={`mb-1.5 rounded-lg border px-3 py-2.5 ${ + isSelected + ? "border-accent-8 bg-accent-3" + : "border-gray-6 bg-gray-3" + }`} + > + + {isMultiSelect ? ( + isSelected ? ( + + ) : ( + + ) + ) : isSelected ? ( + + ) : ( + + )} + + {option.label} + + + {option.description && ( + + {option.description} + + )} + + ); + })} + + {/* Other option */} + + + Other... + + + + {isOtherShown && ( + { + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, text); + setOtherTexts(newTexts); + }} + multiline + autoFocus + /> + )} + + + {/* Submit */} + + + + {isLastQuestion ? "Submit" : "Next"} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx new file mode 100644 index 000000000..256fdbcb8 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -0,0 +1,143 @@ +import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; +import { useEffect, useRef } from "react"; +import { + Animated, + Easing, + LayoutAnimation, + PanResponder, + Text, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { Task } from "../types"; +import { TaskItem } from "./TaskItem"; + +const SWIPE_THRESHOLD = 60; + +interface SwipeableTaskItemProps { + task: Task; + isArchived: boolean; + onPress: (task: Task) => void; + onArchive: (taskId: string) => void; + onUnarchive: (taskId: string) => void; + onSwipeStart?: () => void; + onSwipeEnd?: () => void; +} + +export function SwipeableTaskItem({ + task, + isArchived, + onPress, + onArchive, + onUnarchive, + onSwipeStart, + onSwipeEnd, +}: SwipeableTaskItemProps) { + const themeColors = useThemeColors(); + const translateX = useRef(new Animated.Value(0)).current; + const actionTriggeredRef = useRef(false); + + // Reset position when the item reappears (e.g. moved between sections) + useEffect(() => { + translateX.setValue(0); + actionTriggeredRef.current = false; + }, [isArchived, translateX]); + + const panResponder = useRef( + PanResponder.create({ + // Start tracking immediately on horizontal movement + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, gesture) => + Math.abs(gesture.dx) > 5 && + Math.abs(gesture.dx) > Math.abs(gesture.dy) && + gesture.dx < 0, + // Capture before children so FlatList doesn't steal + onMoveShouldSetPanResponderCapture: (_, gesture) => + Math.abs(gesture.dx) > 8 && + Math.abs(gesture.dx) > Math.abs(gesture.dy * 1.2) && + gesture.dx < 0, + // Never let go once we have the gesture + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => true, + onPanResponderGrant: () => { + actionTriggeredRef.current = false; + onSwipeStart?.(); + }, + onPanResponderMove: Animated.event( + [null, { dx: translateX }], + { + useNativeDriver: false, + listener: (_: unknown, gesture: { dx: number }) => { + // Clamp to left-only + if (gesture.dx > 0) translateX.setValue(0); + }, + }, + ), + onPanResponderRelease: (_, gesture) => { + onSwipeEnd?.(); + if (gesture.dx < -SWIPE_THRESHOLD && !actionTriggeredRef.current) { + actionTriggeredRef.current = true; + Animated.timing(translateX, { + toValue: -400, + duration: 150, + easing: Easing.in(Easing.ease), + useNativeDriver: true, + }).start(() => { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ); + if (isArchived) { + onUnarchive(task.id); + } else { + onArchive(task.id); + } + }); + } else { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + tension: 40, + friction: 8, + }).start(); + } + }, + onPanResponderTerminate: () => { + onSwipeEnd?.(); + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + }).start(); + }, + }), + ).current; + + const actionBg = isArchived ? themeColors.accent[9] : themeColors.gray[8]; + const ActionIcon = isArchived ? ArrowCounterClockwise : Archive; + const actionLabel = isArchived ? "Restore" : "Archive"; + + return ( + + {/* Action revealed behind the row */} + + + + {actionLabel} + + + + {/* Sliding task row */} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index 6b92195d7..e067abda7 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -36,7 +36,7 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { const prUrl = task.latest_run?.output?.pr_url as string | undefined; const hasPR = !!prUrl; const status = hasPR ? "completed" : task.latest_run?.status || "backlog"; - const isCloudTask = task.latest_run?.environment === "cloud"; + const environment = task.latest_run?.environment; const statusColors = statusColorMap[status] || statusColorMap.backlog; @@ -56,10 +56,15 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { - {/* Cloud indicator */} - {isCloudTask && ( - - ☁️ + {/* Environment badge */} + {environment === "cloud" && ( + + Cloud + + )} + {environment === "local" && ( + + Local )} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 88e91a10f..fbcd8c9dc 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,5 +1,7 @@ import { Text } from "@components/text"; import * as WebBrowser from "expo-web-browser"; +import { CaretRight } from "phosphor-react-native"; +import { useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -11,8 +13,9 @@ import { useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; import { useIntegrations } from "../hooks/useIntegrations"; import { useTasks } from "../hooks/useTasks"; +import { useArchivedTasksStore } from "../stores/archivedTasksStore"; import type { Task } from "../types"; -import { TaskItem } from "./TaskItem"; +import { SwipeableTaskItem } from "./SwipeableTaskItem"; interface TaskListProps { onTaskPress?: (taskId: string) => void; @@ -108,11 +111,18 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { ); } +type ListItem = + | { type: "task"; task: Task; isArchived: boolean } + | { type: "archived-header"; count: number; expanded: boolean }; + export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { const { tasks, isLoading, error, refetch } = useTasks(); const { hasGithubIntegration, refetch: refetchIntegrations } = useIntegrations(); const themeColors = useThemeColors(); + const { archivedTasks, archive, unarchive } = useArchivedTasksStore(); + const [archivedExpanded, setArchivedExpanded] = useState(false); + const [scrollEnabled, setScrollEnabled] = useState(true); const handleTaskPress = (task: Task) => { onTaskPress?.(task.id); @@ -122,6 +132,46 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { await Promise.all([refetch(), refetchIntegrations()]); }; + const listItems = useMemo((): ListItem[] => { + const active: Task[] = []; + const archived: Task[] = []; + + for (const task of tasks) { + if (task.id in archivedTasks) { + archived.push(task); + } else { + active.push(task); + } + } + + // Sort archived by FIFO (earliest archived first) + archived.sort( + (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), + ); + + const items: ListItem[] = active.map((task) => ({ + type: "task", + task, + isArchived: false, + })); + + if (archived.length > 0) { + items.push({ + type: "archived-header", + count: archived.length, + expanded: archivedExpanded, + }); + + if (archivedExpanded) { + for (const task of archived) { + items.push({ type: "task", task, isArchived: true }); + } + } + } + + return items; + }, [tasks, archivedTasks, archivedExpanded]); + if (error) { return ( @@ -160,14 +210,51 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { return ; } - // Has tasks - show the list (regardless of GitHub connection status) return ( item.id} - renderItem={({ item }) => ( - - )} + scrollEnabled={scrollEnabled} + data={listItems} + keyExtractor={(item) => + item.type === "archived-header" + ? "__archived_header__" + : `${item.task.id}-${item.isArchived ? "a" : "v"}` + } + renderItem={({ item }) => { + if (item.type === "archived-header") { + return ( + setArchivedExpanded(!item.expanded)} + className="flex-row items-center gap-2 border-gray-6 border-t bg-gray-2 px-3 py-2.5" + > + + + Archived + + {item.count} + + ); + } + + return ( + setScrollEnabled(false)} + onSwipeEnd={() => setScrollEnabled(true)} + /> + ); + }} refreshControl={ ; + customInput?: string; + displayText: string; +} interface TaskSessionViewProps { events: SessionEvent[]; - isPromptPending: boolean; + isConnecting?: boolean; + isThinking?: boolean; + terminalStatus?: "failed" | "completed"; + lastError?: string | null; + onRetry?: () => void; onOpenTask?: (taskId: string) => void; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; contentContainerStyle?: object; } @@ -22,13 +45,16 @@ interface ToolData { status: ToolStatus; args?: Record; result?: unknown; + isAgent?: boolean; + parentToolCallId?: string; } interface ParsedMessage { id: string; - type: "user" | "agent" | "tool"; + type: "user" | "agent" | "thought" | "tool" | "connecting" | "thinking"; content: string; toolData?: ToolData; + children?: ParsedMessage[]; } function mapToolStatus( @@ -48,11 +74,14 @@ function mapToolStatus( } } -function parseSessionNotification(notification: SessionNotification): { - type: "user" | "agent" | "tool" | "tool_update"; - content?: string; - toolData?: ToolData; -} | null { +type ParsedNotification = + | { type: "user" | "agent" | "agent_complete" | "thought"; content: string } + | { type: "tool" | "tool_update"; toolData: ToolData } + | { type: "plan"; entries: PlanEntry[] }; + +function parseSessionNotification( + notification: SessionNotification, +): ParsedNotification | null { const { update } = notification; if (!update?.sessionUpdate) { return null; @@ -70,7 +99,27 @@ function parseSessionNotification(notification: SessionNotification): { } return null; } + // `agent_message` is the aggregated final message emitted by the server + // once a response is complete. If we already received streaming chunks, + // this is a duplicate — replace pending text instead of appending. + case "agent_message": { + if (update.content?.type === "text") { + return { + type: "agent_complete" as const, + content: update.content.text, + }; + } + return null; + } + case "agent_thought_chunk": { + if (update.content?.type === "text") { + return { type: "thought", content: update.content.text }; + } + return null; + } case "tool_call": { + const meta = update._meta?.claudeCode; + const isAgent = meta?.toolName === "Agent" || meta?.toolName === "Task"; return { type: "tool", toolData: { @@ -78,10 +127,13 @@ function parseSessionNotification(notification: SessionNotification): { toolCallId: update.toolCallId ?? "", status: mapToolStatus(update.status), args: update.rawInput, + isAgent, + parentToolCallId: meta?.parentToolCallId, }, }; } case "tool_call_update": { + const meta = update._meta?.claudeCode; return { type: "tool_update", toolData: { @@ -90,31 +142,124 @@ function parseSessionNotification(notification: SessionNotification): { status: mapToolStatus(update.status), args: update.rawInput, result: update.rawOutput, + parentToolCallId: meta?.parentToolCallId, }, }; } + case "plan": { + if (Array.isArray(update.entries)) { + return { type: "plan", entries: update.entries }; + } + return null; + } default: return null; } } -function processEvents(events: SessionEvent[]): ParsedMessage[] { - const messages: ParsedMessage[] = []; - let pendingAgentText = ""; - let agentMessageCount = 0; - const toolMessages = new Map(); +interface ProcessedEvents { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; +} + +function isQuestionTool(toolData?: ToolData): boolean { + if (!toolData) return false; + if (toolData.toolName.toLowerCase().includes("question")) return true; + if (Array.isArray(toolData.args?.questions)) return true; + return false; +} + +// Mutable processor state persisted across renders via useRef. +// Only new events (past processedIdx) are processed on each call. +interface EventProcessorState { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; + pendingAgentText: string; + pendingThoughtText: string; + lastAgentMsgIdx: number | null; + agentMessageCount: number; + thoughtMessageCount: number; + userMessageCount: number; + toolMessages: Map; + // Maps agent toolCallId → agent ParsedMessage for nesting children + agentTools: Map; + processedIdx: number; + // Snapshot tracking: only create a new array ref when messages grow. + // Mutations (tool_update, agent_complete replacing content) reuse the + // same snapshot so FlatList doesn't re-layout and reset scroll position. + lastSnapshot: ParsedMessage[]; + lastSnapshotLength: number; +} + +function createProcessorState(): EventProcessorState { + return { + messages: [], + plan: null, + pendingAgentText: "", + pendingThoughtText: "", + lastAgentMsgIdx: null, + agentMessageCount: 0, + thoughtMessageCount: 0, + userMessageCount: 0, + toolMessages: new Map(), + agentTools: new Map(), + processedIdx: 0, + lastSnapshot: [], + lastSnapshotLength: 0, + }; +} + +function processNewEvents( + state: EventProcessorState, + events: SessionEvent[], +): ProcessedEvents { + // If events shrank (e.g. session reset), start fresh + if (events.length < state.processedIdx) { + Object.assign(state, createProcessorState()); + } + + // Nothing new to process + if (events.length === state.processedIdx) { + return { messages: state.messages, plan: state.plan }; + } const flushAgentText = () => { - if (!pendingAgentText) return; - messages.push({ - id: `agent-${agentMessageCount++}`, + if (!state.pendingAgentText) return; + const msg: ParsedMessage = { + id: `agent-${state.agentMessageCount++}`, type: "agent", - content: pendingAgentText, - }); - pendingAgentText = ""; + content: state.pendingAgentText, + }; + state.messages.push(msg); + state.lastAgentMsgIdx = state.messages.length - 1; + state.pendingAgentText = ""; + }; + + const flushThoughtText = () => { + if (!state.pendingThoughtText) return; + // Merge consecutive thoughts into one message instead of many rows + const lastMsg = state.messages[state.messages.length - 1]; + if (lastMsg?.type === "thought") { + lastMsg.content += state.pendingThoughtText; + } else { + state.messages.push({ + id: `thought-${state.thoughtMessageCount++}`, + type: "thought", + content: state.pendingThoughtText, + }); + } + state.pendingThoughtText = ""; + }; + + const flushPending = () => { + flushThoughtText(); + flushAgentText(); }; - for (const event of events) { + let hasItemMutation = false; + + for (let i = state.processedIdx; i < events.length; i++) { + const event = events[i]; if (event.type !== "session_update") continue; const parsed = parseSessionNotification(event.notification); @@ -122,53 +267,417 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { switch (parsed.type) { case "user": - flushAgentText(); - messages.push({ - id: `user-${event.ts}`, + flushPending(); + state.messages.push({ + id: `user-${state.userMessageCount++}`, type: "user", content: parsed.content ?? "", }); + state.lastAgentMsgIdx = null; break; case "agent": - pendingAgentText += parsed.content ?? ""; + flushThoughtText(); + state.pendingAgentText += parsed.content ?? ""; break; - case "tool": + case "agent_complete": + flushThoughtText(); + // If we already flushed an agent message from chunks, replace it + if ( + state.lastAgentMsgIdx !== null && + state.messages[state.lastAgentMsgIdx]?.type === "agent" + ) { + state.messages[state.lastAgentMsgIdx].content = parsed.content ?? ""; + state.pendingAgentText = ""; + } else { + state.pendingAgentText = parsed.content ?? ""; + } + break; + case "thought": flushAgentText(); + state.pendingThoughtText += parsed.content ?? ""; + break; + case "plan": + state.plan = parsed.entries; + break; + case "tool": + flushPending(); if (parsed.toolData) { - const msg: ParsedMessage = { - id: `tool-${parsed.toolData.toolCallId}`, - type: "tool", - content: "", - toolData: parsed.toolData, - }; - toolMessages.set(parsed.toolData.toolCallId, msg); - messages.push(msg); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); + if (existing?.toolData) { + existing.toolData = { + ...existing.toolData, + ...parsed.toolData, + }; + } else { + const msg: ParsedMessage = { + id: `tool-${parsed.toolData.toolCallId}`, + type: "tool", + content: "", + toolData: parsed.toolData, + children: parsed.toolData.isAgent ? [] : undefined, + }; + state.toolMessages.set(parsed.toolData.toolCallId, msg); + + // Agent tools: register for child nesting + if (parsed.toolData.isAgent) { + state.agentTools.set(parsed.toolData.toolCallId, msg); + } + + // Child tools: nest under parent agent instead of top-level + const parentId = parsed.toolData.parentToolCallId; + const parent = parentId + ? state.agentTools.get(parentId) + : undefined; + if (parent?.children) { + parent.children.push(msg); + hasItemMutation = true; + } else { + state.messages.push(msg); + } + } } + state.lastAgentMsgIdx = null; break; case "tool_update": if (parsed.toolData) { - const existing = toolMessages.get(parsed.toolData.toolCallId); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); if (existing?.toolData) { existing.toolData.status = parsed.toolData.status; existing.toolData.result = parsed.toolData.result; + if (parsed.toolData.args) { + existing.toolData.args = parsed.toolData.args; + } + hasItemMutation = true; } } break; } } - flushAgentText(); - return messages; + flushPending(); + state.processedIdx = events.length; + + // Create a new array reference when messages were added or when a tool + // received args for the first time (so the diff view can render). + // Pure status/text mutations reuse the prior snapshot to avoid jumps. + if (state.messages.length !== state.lastSnapshotLength || hasItemMutation) { + state.lastSnapshot = [...state.messages]; + state.lastSnapshotLength = state.messages.length; + } + + return { messages: state.lastSnapshot, plan: state.plan }; +} + +function CollapsedThought({ content }: { content: string }) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + + return ( + setExpanded(!expanded)} className="px-4 py-0.5"> + + + Thought + + {expanded && ( + + {content} + + )} + + ); +} + +// Detect objects like {"0":"E","1":"r","2":"r",...,"isError":true} — a string +// serialized as char-per-key (possibly with extra metadata keys mixed in). +function tryReassembleString(obj: Record): string | null { + const numericKeys = Object.keys(obj).filter((k) => /^\d+$/.test(k)); + if (numericKeys.length < 3) return null; + if ( + numericKeys.every( + (k) => typeof obj[k] === "string" && (obj[k] as string).length === 1, + ) + ) { + return numericKeys + .sort((a, b) => Number(a) - Number(b)) + .map((k) => obj[k]) + .join(""); + } + return null; +} + +function extractErrorText(result: unknown): string | null { + if (typeof result === "string") return result; + if (Array.isArray(result)) { + const texts = result.map(extractErrorText).filter(Boolean); + return texts.length > 0 ? texts.join("\n") : null; + } + if (!result || typeof result !== "object") return null; + const obj = result as Record; + + // Reassemble char-per-key strings: {"0":"E","1":"r",...} + const reassembled = tryReassembleString(obj); + if (reassembled) return reassembled; + + // Check simple string fields, recurse into nested objects + for (const key of [ + "error", + "message", + "stderr", + "output", + "text", + "content", + ]) { + if (typeof obj[key] === "string") return obj[key] as string; + if (obj[key] && typeof obj[key] === "object") { + const nested = extractErrorText(obj[key]); + if (nested) return nested; + } + } + + // Last resort: stringify the result so *something* shows + try { + const str = JSON.stringify(result, null, 2); + if (str && str !== "{}") return str; + } catch { + // ignore + } + + return null; +} + +function agentPromptSummary(args?: Record): string | null { + if (!args) return null; + const prompt = + typeof args.prompt === "string" + ? args.prompt + : typeof args.description === "string" + ? args.description + : null; + if (!prompt) return null; + // Take the first meaningful line, truncated + const firstLine = prompt + .split("\n") + .find((l) => l.trim()) + ?.trim(); + if (!firstLine) return null; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; +} + +function AgentToolCard({ + item, + onOpenTask, +}: { + item: ParsedMessage; + onOpenTask?: (taskId: string) => void; +}) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + const toolData = item.toolData; + const children = item.children ?? []; + if (!toolData) return null; + + const isLoading = + toolData.status === "pending" || toolData.status === "running"; + const isFailed = toolData.status === "error"; + const childCount = children.length; + const subtitle = agentPromptSummary(toolData.args); + const errorText = isFailed ? extractErrorText(toolData.result) : null; + + return ( + + {/* Header */} + setExpanded(!expanded)} className="px-3 py-2"> + + {isLoading ? ( + + ) : ( + + )} + + {toolData.toolName} + + {childCount > 0 && ( + + {childCount} {childCount === 1 ? "tool" : "tools"} + + )} + {isFailed && ( + + Failed + + )} + + + {subtitle && ( + + {subtitle} + + )} + + + {/* Error message + nested tool calls */} + {expanded && ( + + {errorText && ( + + + {errorText} + + + )} + {children.map((child) => { + if (!child.toolData) return null; + return ( + + ); + })} + + )} + + ); +} + +function formatElapsed(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +function useElapsedTimer() { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + setElapsed(0); + const interval = setInterval(() => { + setElapsed((e) => e + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + return elapsed; +} + +function ThinkingIndicator() { + const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + Thinking{".".repeat(dots)} + + {formatElapsed(elapsed)} + + ); +} + +function ConnectingIndicator() { + const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + Connecting{".".repeat(dots)} + + {formatElapsed(elapsed)} + + ); } export function TaskSessionView({ events, - isPromptPending, + isConnecting, + isThinking, + terminalStatus, + lastError, + onRetry, onOpenTask, + onSendPermissionResponse, contentContainerStyle, }: TaskSessionViewProps) { - const messages = useMemo(() => processEvents(events), [events]); + const processorRef = useRef(createProcessorState()); + const prevEventsRef = useRef(events); + // Reset processor when events array shrinks or changes identity completely + // (e.g., navigating between tasks while Expo Router reuses the component). + if ( + events.length === 0 || + (events !== prevEventsRef.current && events[0] !== prevEventsRef.current[0]) + ) { + processorRef.current = createProcessorState(); + } + prevEventsRef.current = events; + const { messages, plan } = useMemo( + () => processNewEvents(processorRef.current, events), + [events], + ); + // Inverted FlatList renders data[0] at the visual bottom. + // Reverse so newest messages are at index 0 = bottom. + const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); + const flatListRef = useRef(null); + const buttonRef = useRef(null); + const isScrolledRef = useRef(false); + + const scrollToBottom = useCallback(() => { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, []); + + const handleScroll = useCallback( + (e: { nativeEvent: { contentOffset: { y: number } } }) => { + const scrolled = e.nativeEvent.contentOffset.y > 0; + if (scrolled !== isScrolledRef.current) { + isScrolledRef.current = scrolled; + buttonRef.current?.setNativeProps({ + style: { + opacity: scrolled ? 1 : 0, + pointerEvents: scrolled ? "auto" : "none", + }, + }); + } + }, + [], + ); const renderMessage = useCallback( ({ item }: { item: ParsedMessage }) => { @@ -179,46 +688,132 @@ export function TaskSessionView({ return ( ); + case "thought": + return ; case "tool": - return item.toolData ? ( + if (!item.toolData) return null; + if (isQuestionTool(item.toolData)) { + return ( + + ); + } + if (item.toolData.isAgent) { + return ; + } + return ( - ) : null; + ); default: return null; } }, - [onOpenTask], + [onOpenTask, onSendPermissionResponse], ); return ( - item.id} - inverted - contentContainerStyle={{ - flexDirection: "column-reverse", - ...contentContainerStyle, - }} - keyboardDismissMode="interactive" - keyboardShouldPersistTaps="handled" - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - isPromptPending ? ( - - - - Thinking... - - - ) : null - } - /> + + + item.id} + inverted + contentContainerStyle={contentContainerStyle} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator + onScroll={handleScroll} + scrollEventThrottle={100} + maxToRenderPerBatch={15} + windowSize={21} + initialNumToRender={30} + ListHeaderComponent={ + terminalStatus ? ( + + + {terminalStatus === "failed" ? "Run failed" : "Run completed"} + + {lastError && ( + {lastError} + )} + {onRetry && ( + + + {terminalStatus === "failed" ? "Retry" : "Continue"} + + + )} + + ) : null + } + /> + {/* Thinking/connecting indicators absolutely positioned above the Composer area. + Rendered outside FlatList to avoid inverted-list double-mount bugs. */} + {(isConnecting || isThinking) && ( + + {isConnecting ? ( + + ) : isThinking ? ( + + ) : null} + + )} + + + + + + ); } diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index c0bb1f812..63727f1d8 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -14,7 +14,7 @@ import type { CreateTaskOptions, Task } from "../types"; export const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { repository?: string; createdBy?: number }) => + list: (filters?: { repository?: string }) => [...taskKeys.lists(), filters] as const, details: () => [...taskKeys.all, "detail"] as const, detail: (id: string) => [...taskKeys.details(), id] as const, @@ -27,7 +27,6 @@ export function useTasks(filters?: { repository?: string }) { const queryFilters = { ...filters, - createdBy: currentUser?.id, }; const query = useQuery({ @@ -64,23 +63,15 @@ export function useTask(taskId: string) { export function useCreateTask() { const queryClient = useQueryClient(); - const { data: currentUser } = useUserQuery(); - const invalidateTasks = (newTask?: Task) => { - if (newTask && currentUser?.id) { - // Update the correct cache entry with the user's filter - const queryKey = taskKeys.list({ createdBy: currentUser.id }); - queryClient.setQueryData(queryKey, (old) => - old ? [newTask, ...old] : [newTask], - ); - } + const invalidateTasks = () => { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); }; const mutation = useMutation({ mutationFn: (options: CreateTaskOptions) => createTask(options), - onSuccess: (newTask) => { - invalidateTasks(newTask); + onSuccess: () => { + invalidateTasks(); }, onError: (error) => { console.error("Failed to create task:", error.message); diff --git a/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts new file mode 100644 index 000000000..8a2def9f4 --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts @@ -0,0 +1,40 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +interface ArchivedTasksState { + // taskId → timestamp (ms) for FIFO ordering + archivedTasks: Record; + archive: (taskId: string) => void; + unarchive: (taskId: string) => void; + isArchived: (taskId: string) => boolean; +} + +export const useArchivedTasksStore = create()( + persist( + (set, get) => ({ + archivedTasks: {}, + + archive: (taskId: string) => + set((state) => ({ + archivedTasks: { + ...state.archivedTasks, + [taskId]: Date.now(), + }, + })), + + unarchive: (taskId: string) => + set((state) => { + const { [taskId]: _, ...rest } = state.archivedTasks; + return { archivedTasks: rest }; + }), + + isArchived: (taskId: string) => taskId in get().archivedTasks, + }), + { + name: "archived-tasks", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ archivedTasks: state.archivedTasks }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index a11ac1383..994ec81d0 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,6 +1,14 @@ import { create } from "zustand"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; -import { appendTaskRunLog, fetchS3Logs, runTaskInCloud } from "../api"; +import { + CloudCommandError, + fetchS3Logs, + getTask, + getTaskRun, + runTaskInCloud, + sendCloudCommand, +} from "../api"; import type { SessionEvent, SessionNotification, @@ -11,6 +19,46 @@ import { convertRawEntriesToEvents, parseSessionLogs, } from "../utils/parseSessionLogs"; +import { playMeepSound } from "../utils/sounds"; + +// Infer whether the agent is actively working or idle (waiting for user input). +// Primary signal: _posthog/turn_complete or _posthog/task_complete in raw log +// entries. Fallback: session update notification heuristic for older logs. +function inferAgentIsIdle( + rawEntries: StoredLogEntry[], + notifications: SessionNotification[], +): boolean { + // Check raw entries for explicit turn/task completion signals + for (let i = rawEntries.length - 1; i >= 0; i--) { + const method = rawEntries[i].notification?.method; + if ( + method === "_posthog/turn_complete" || + method === "_posthog/task_complete" + ) { + return true; + } + // If we hit a client-direction entry (user message), the agent hasn't + // completed a turn since the last user input. + if (rawEntries[i].direction === "client") break; + } + + // Fallback: check session update notifications for agent responses + for (let i = notifications.length - 1; i >= 0; i--) { + const su = notifications[i].update?.sessionUpdate; + if (su === "agent_message" || su === "agent_message_chunk") { + return true; + } + if ( + su === "user_message_chunk" || + su === "tool_call" || + su === "tool_call_update" || + su === "agent_thought_chunk" + ) { + return false; + } + } + return false; +} const CLOUD_POLLING_INTERVAL_MS = 500; @@ -23,6 +71,20 @@ export interface TaskSession { logUrl: string; processedLineCount: number; processedHashes?: Set; + // Content of user prompts echoed locally (before the agent writes them to + // the log). Used by polling to dedup the canonical copy against the echo. + localUserEchoes?: Set; + // Terminal backend status for this run, populated by the status-check + // poller so the UI can surface "Run failed" / "Run completed". + terminalStatus?: "failed" | "completed"; + lastError?: string | null; + // True when the user initiated work (new task, sendPrompt, resume) and + // we should play a sound when control returns. False when reconnecting + // to an already-running task to avoid spurious pings. + awaitingPing?: boolean; + // Messages queued while the agent is working. Auto-sent when control + // returns (isPromptPending flips to false). + messageQueue?: string[]; } interface TaskSessionStore { @@ -31,16 +93,42 @@ interface TaskSessionStore { connectToTask: (task: Task) => Promise; disconnectFromTask: (taskId: string) => void; sendPrompt: (taskId: string, prompt: string) => Promise; + sendPermissionResponse: ( + taskId: string, + args: { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; + }, + ) => Promise; cancelPrompt: (taskId: string) => Promise; getSessionForTask: (taskId: string) => TaskSession | undefined; _handleEvent: (taskRunId: string, event: SessionEvent) => void; _startCloudPolling: (taskRunId: string, logUrl: string) => void; _stopCloudPolling: (taskRunId: string) => void; + _resumeCloudRun: ( + taskId: string, + previousRunId: string, + prompt: string, + ) => Promise; } const cloudPollers = new Map>(); const connectAttempts = new Set(); +// Guard against overlapping poll ticks — if a fetch takes >500ms, the next +// interval fires while the previous is still running, causing both to read +// the same processedLineCount and produce duplicate events. +const pollInFlight = new Set(); +// Timestamps for when each poll tick started — used to force-clear stuck ticks. +const pollInFlightSince = new Map(); +const POLL_IN_FLIGHT_TIMEOUT_MS = 30_000; +// Tick counts per task run used to throttle backend task-run status polling. +const pollTicks = new Map(); +// How many S3 polling ticks between each backend task-run status check. +const STATUS_CHECK_TICK_INTERVAL = 5; export const useTaskSessionStore = create((set, get) => ({ sessions: {}, @@ -49,7 +137,7 @@ export const useTaskSessionStore = create((set, get) => ({ const taskId = task.id; const latestRunId = task.latest_run?.id; const latestRunLogUrl = task.latest_run?.log_url; - const taskDescription = task.description; + const _taskDescription = task.description; if (connectAttempts.has(taskId)) { logger.debug("Connection already in progress", { taskId }); @@ -82,24 +170,12 @@ export const useTaskSessionStore = create((set, get) => ({ [newRunId]: { taskRunId: newRunId, taskId, - events: taskDescription - ? [ - { - type: "session_update" as const, - ts: Date.now(), - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }, - ] - : [], + events: [], status: "connected", - isPromptPending: true, // Agent is processing initial task + isPromptPending: true, logUrl: newLogUrl, processedLineCount: 0, + awaitingPing: true, }, }, })); @@ -121,29 +197,26 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Loaded cloud historical logs", { notifications: notifications.length, rawEntries: rawEntries.length, + backendStatus: task.latest_run?.status, }); const historicalEvents = convertRawEntriesToEvents( rawEntries, notifications, - taskDescription, ); - // Check if agent is still processing by looking at the last entry - // If the last non-client entry is a user message, agent is likely still working - const lastAgentEntry = [...rawEntries] - .reverse() - .find((e) => e.direction !== "client"); - // biome-ignore lint/suspicious/noExplicitAny: Entry structure varies - const lastUpdate = (lastAgentEntry?.notification as any)?.params?.update - ?.sessionUpdate; - const isAgentResponding = - lastUpdate === "agent_message_chunk" || - lastUpdate === "agent_thought_chunk" || - lastUpdate === "tool_call" || - lastUpdate === "tool_call_update"; - // If we have entries but the last one isn't an agent response, agent may still be processing - const isPromptPending = rawEntries.length > 0 && !isAgentResponding; + // Terminal runs (completed/failed) always clear isPromptPending. + // For non-terminal runs we infer idle vs working from the log shape + // because the backend has no "waiting_for_input" status. + const backendStatus = task.latest_run?.status; + const isTerminal = + backendStatus === "completed" || backendStatus === "failed"; + const terminalStatus: "completed" | "failed" | undefined = isTerminal + ? (backendStatus as "completed" | "failed") + : undefined; + const lastError = isTerminal + ? (task.latest_run?.error_message ?? null) + : null; set((state) => ({ sessions: { @@ -153,15 +226,24 @@ export const useTaskSessionStore = create((set, get) => ({ taskId, events: historicalEvents, status: "connected", - isPromptPending, + isPromptPending: isTerminal + ? false + : !inferAgentIsIdle(rawEntries, notifications), logUrl: latestRunLogUrl, processedLineCount: rawEntries.length, + terminalStatus, + lastError, }, }, })); get()._startCloudPolling(latestRunId, latestRunLogUrl); - logger.debug("Connected to cloud session", { taskId, latestRunId }); + logger.debug("Connected to cloud session", { + taskId, + latestRunId, + backendStatus, + isTerminal, + }); } catch (error) { logger.error("Failed to connect to task", error); } finally { @@ -188,67 +270,191 @@ export const useTaskSessionStore = create((set, get) => ({ throw new Error("No active session for task"); } - const notification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", - notification: { - method: "session/update", - params: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: prompt }, + // If the agent is still working, queue the message for later. + if (session.isPromptPending) { + logger.debug("Agent busy, queuing message", { taskId }); + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + messageQueue: [...(current.messageQueue ?? []), prompt], + }, }, + }; + }); + return; + } + + // Local echo for immediate UX feedback — polling will re-surface the + // canonical copy once the agent writes it to the log; any duplicate is + // removed by content-based dedup in the polling loop below. + const ts = Date.now(); + const userEvent: SessionEvent = { + type: "session_update", + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: prompt }, }, }, }; - await appendTaskRunLog(taskId, session.taskRunId, [notification]); - logger.debug("Sent cloud message via S3", { - taskId, - runId: session.taskRunId, + set((state) => { + const current = state.sessions[session.taskRunId]; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + awaitingPing: true, + }, + }, + }; }); + try { + await sendCloudCommand(taskId, session.taskRunId, "user_message", { + content: prompt, + }); + logger.debug("Sent cloud command user_message", { + taskId, + runId: session.taskRunId, + }); + } catch (err) { + // Sandbox for this run has shut down — create a resume run on the + // backend and swap the local session to the new run id. + let rollbackError: unknown = err; + if (err instanceof CloudCommandError && err.isSandboxInactive()) { + logger.info("Sandbox inactive, creating resume run", { + taskId, + previousRunId: session.taskRunId, + }); + try { + await get()._resumeCloudRun(taskId, session.taskRunId, prompt); + return; + } catch (resumeErr) { + logger.error("Failed to resume cloud run", resumeErr); + rollbackError = resumeErr; + } + } + + // Roll back the local echo + pending state so the user can retry. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw rollbackError; + } + }, + + // Resolve an outstanding requestPermission on the desktop/agent side + // (e.g. AskUserQuestion). Unlike sendPrompt, this never queues — a + // permission reply only makes sense while the agent is paused inside + // requestPermission, and it completes an existing turn rather than + // starting a new one. + sendPermissionResponse: async (taskId, args) => { + const session = get().getSessionForTask(taskId); + if (!session) { + throw new Error("No active session for task"); + } + const ts = Date.now(); const userEvent: SessionEvent = { type: "session_update", ts, - notification: notification.notification?.params as SessionNotification, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: args.displayText }, + }, + }, }; - set((state) => ({ - sessions: { - ...state.sessions, - [session.taskRunId]: { - ...state.sessions[session.taskRunId], - events: [...state.sessions[session.taskRunId].events, userEvent], - processedLineCount: - (state.sessions[session.taskRunId].processedLineCount ?? 0) + 1, - isPromptPending: true, + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + awaitingPing: true, + }, }, - }, - })); + }; + }); + + try { + await sendCloudCommand(taskId, session.taskRunId, "permission_response", { + toolCallId: args.toolCallId, + optionId: args.optionId, + ...(args.answers ? { answers: args.answers } : {}), + ...(args.customInput ? { customInput: args.customInput } : {}), + }); + logger.debug("Sent permission_response", { + taskId, + runId: session.taskRunId, + toolCallId: args.toolCallId, + }); + } catch (err) { + logger.error("Failed to send permission_response", err); + // Roll back the optimistic state so the UI reflects reality. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw err; + } }, cancelPrompt: async (taskId: string) => { const session = get().getSessionForTask(taskId); if (!session) return false; - const cancelNotification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", - notification: { - method: "session/cancel", - params: { - sessionId: session.taskRunId, - }, - }, - }; - try { - await appendTaskRunLog(taskId, session.taskRunId, [cancelNotification]); - logger.debug("Sent cancel request via S3", { + await sendCloudCommand(taskId, session.taskRunId, "cancel"); + logger.debug("Sent cancel command", { taskId, runId: session.taskRunId, }); @@ -295,6 +501,17 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Starting cloud S3 polling", { taskRunId }); const pollS3 = async () => { + // Skip if previous tick is still in flight — but force-clear if stuck + if (pollInFlight.has(taskRunId)) { + const startedAt = pollInFlightSince.get(taskRunId) ?? 0; + if (Date.now() - startedAt < POLL_IN_FLIGHT_TIMEOUT_MS) return; + logger.warn("Force-clearing stuck pollInFlight", { taskRunId }); + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); + } + pollInFlight.add(taskRunId); + pollInFlightSince.set(taskRunId, Date.now()); + try { const session = get().sessions[taskRunId]; if (!session) { @@ -302,6 +519,56 @@ export const useTaskSessionStore = create((set, get) => ({ return; } + // Check backend status periodically, or every tick while the agent + // is pending (so "Thinking..." clears promptly when the run finishes). + const tick = (pollTicks.get(taskRunId) ?? 0) + 1; + pollTicks.set(taskRunId, tick); + const shouldCheckStatus = + session.isPromptPending || tick % STATUS_CHECK_TICK_INTERVAL === 0; + if (shouldCheckStatus) { + try { + const run = await getTaskRun(session.taskId, taskRunId); + logger.debug("Status check", { + taskRunId, + status: run.status, + error: run.error_message, + }); + if (run.status === "failed" || run.status === "completed") { + logger.debug("Backend run reached terminal status", { + taskRunId, + status: run.status, + error: run.error_message, + }); + const shouldPing = + get().sessions[taskRunId]?.awaitingPing ?? false; + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + isPromptPending: false, + terminalStatus: run.status as "failed" | "completed", + lastError: run.error_message, + awaitingPing: false, + messageQueue: undefined, + }, + }, + }; + }); + if (shouldPing && usePreferencesStore.getState().pingsEnabled) { + playMeepSound().catch(() => {}); + } + } + } catch (statusErr) { + logger.warn("Failed to fetch task run status", { + error: statusErr, + }); + } + } + const text = await fetchS3Logs(logUrl); if (!text) return; @@ -310,9 +577,20 @@ export const useTaskSessionStore = create((set, get) => ({ if (lines.length > processedCount) { const newLines = lines.slice(processedCount); + logger.debug("Poll picked up new log lines", { + taskRunId, + newLineCount: newLines.length, + totalLines: lines.length, + }); const currentHashes = new Set(session.processedHashes ?? []); - + const remainingLocalEchoes = new Set(session.localUserEchoes ?? []); + // Collect all new events in a batch, then do a single store + // update. This prevents N re-renders per poll tick. + const batchedEvents: SessionEvent[] = []; let receivedAgentMessage = false; + // Track when a user_message_chunk arrives that wasn't sent from + // this device — means someone prompted from the desktop app. + let receivedExternalUserMessage = false; for (const line of newLines) { try { @@ -321,45 +599,83 @@ export const useTaskSessionStore = create((set, get) => ({ ? new Date(entry.timestamp).getTime() : Date.now(); - const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}-${entry.direction ?? ""}`; + // Build a dedup hash specific enough to distinguish different + // events at the same timestamp. For session/update entries, + // include the update type, toolCallId, and status so that a + // tool_call and its tool_call_update don't collide. + const params = entry.notification?.params; + const suDetail = params?.update + ? `-${params.update.sessionUpdate ?? ""}-${params.update.toolCallId ?? ""}-${params.update.status ?? ""}` + : `-${entry.direction ?? ""}`; + const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}${suDetail}`; if (currentHashes.has(hash)) { continue; } currentHashes.add(hash); - const isClientMessage = entry.direction === "client"; - if (isClientMessage) { - continue; + // Check for local echo dedup BEFORE pushing any events for + // this entry — otherwise the acp_message duplicate gets in. + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification?.params + ) { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + const text = params?.update?.content?.text; + if (text && remainingLocalEchoes.has(text)) { + remainingLocalEchoes.delete(text); + continue; + } + // User message not from this device (e.g. desktop app) + receivedExternalUserMessage = true; + } } - const acpEvent: SessionEvent = { + batchedEvents.push({ type: "acp_message", direction: entry.direction ?? "agent", ts, message: entry.notification, - }; - get()._handleEvent(taskRunId, acpEvent); + }); + + if ( + entry.type === "notification" && + (entry.notification?.method === "_posthog/turn_complete" || + entry.notification?.method === "_posthog/task_complete" || + entry.notification?.method === "_posthog/error" || + // Agent explicitly blocked on a user reply (e.g. a question + // tool invoked via requestPermission). Treat this as a + // turn boundary so the input UI unblocks — otherwise the + // user's answer would be stuck in the "queue while busy" + // path in sendPrompt. + entry.notification?.method === "_posthog/awaiting_user_input") + ) { + receivedAgentMessage = true; + } if ( entry.type === "notification" && entry.notification?.method === "session/update" && entry.notification?.params ) { - const sessionUpdateEvent: SessionEvent = { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + batchedEvents.push({ type: "session_update", ts, - notification: entry.notification - .params as SessionNotification, - }; - get()._handleEvent(taskRunId, sessionUpdateEvent); - - // Check if this is an agent message - means agent is responding - const sessionUpdate = - entry.notification?.params?.update?.sessionUpdate; - if ( - sessionUpdate === "agent_message_chunk" || - sessionUpdate === "agent_thought_chunk" - ) { + notification: params, + }); + + // agent_message (finalized, non-chunk) is a reasonable proxy + // for turn completion — it's emitted once the full response + // is assembled. Chunks and thoughts fire mid-turn and are NOT + // reliable. The proper signal is _posthog/turn_complete but + // it's not yet written to S3 logs by the server. + if (sessionUpdate === "agent_message") { receivedAgentMessage = true; } } @@ -368,23 +684,65 @@ export const useTaskSessionStore = create((set, get) => ({ } } - set((state) => ({ - sessions: { - ...state.sessions, - [taskRunId]: { - ...state.sessions[taskRunId], - processedLineCount: lines.length, - processedHashes: currentHashes, - // Clear pending state when we receive agent response - isPromptPending: receivedAgentMessage - ? false - : (state.sessions[taskRunId]?.isPromptPending ?? false), + // Determine if we should ping. If an external user message armed + // the ping in this same batch, honour it even though the store + // hasn't updated yet. + const wasAwaitingPing = + get().sessions[taskRunId]?.awaitingPing ?? false; + const shouldPingAfterBatch = + receivedAgentMessage && + (wasAwaitingPing || receivedExternalUserMessage); + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + + // Determine isPromptPending: external user message starts work, + // turn/task completion ends it. + let nextIsPromptPending = current.isPromptPending; + if (receivedExternalUserMessage) nextIsPromptPending = true; + if (receivedAgentMessage) nextIsPromptPending = false; + + // awaitingPing: arm when work starts (even from another device), + // disarm when it completes and the ping fires. + let nextAwaitingPing = current.awaitingPing; + if (receivedExternalUserMessage && !current.awaitingPing) { + nextAwaitingPing = true; + } + if (receivedAgentMessage) nextAwaitingPing = false; + + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + events: + batchedEvents.length > 0 + ? [...current.events, ...batchedEvents] + : current.events, + processedLineCount: lines.length, + processedHashes: currentHashes, + localUserEchoes: + remainingLocalEchoes.size > 0 + ? remainingLocalEchoes + : undefined, + isPromptPending: nextIsPromptPending, + awaitingPing: nextAwaitingPing, + }, }, - }, - })); + }; + }); + if ( + shouldPingAfterBatch && + usePreferencesStore.getState().pingsEnabled + ) { + playMeepSound().catch(() => {}); + } } } catch (err) { logger.warn("Cloud polling error", { error: err }); + } finally { + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); } }; @@ -398,7 +756,111 @@ export const useTaskSessionStore = create((set, get) => ({ if (interval) { clearInterval(interval); cloudPollers.delete(taskRunId); + pollTicks.delete(taskRunId); logger.debug("Stopped cloud S3 polling", { taskRunId }); } }, + + _resumeCloudRun: async ( + taskId: string, + previousRunId: string, + prompt: string, + ) => { + // Fetch the latest task to pick up the branch the previous run was using — + // otherwise the backend would create a new branch and we'd lose working + // tree context. + const freshTask = await getTask(taskId); + const previousBranch = freshTask.latest_run?.branch ?? null; + + const updatedTask = await runTaskInCloud(taskId, { + branch: previousBranch, + resumeFromRunId: previousRunId, + pendingUserMessage: prompt, + }); + + const newRun = updatedTask.latest_run; + if (!newRun?.id || !newRun.log_url) { + throw new Error("Resume run was created but has no id or log_url"); + } + + // Stop polling the dead run and swap the session over to the new run id. + // Read the CURRENT session state to preserve the local echo that was + // just added in sendPrompt (the captured `session` variable in the + // caller is stale). + get()._stopCloudPolling(previousRunId); + + set((state) => { + const previousSession = state.sessions[previousRunId]; + if (!previousSession) return state; + const { [previousRunId]: _old, ...rest } = state.sessions; + return { + sessions: { + ...rest, + [newRun.id]: { + ...previousSession, + taskRunId: newRun.id, + logUrl: newRun.log_url, + status: "connected", + isPromptPending: true, + processedLineCount: 0, + processedHashes: new Set(), + awaitingPing: true, + }, + }, + }; + }); + + get()._startCloudPolling(newRun.id, newRun.log_url); + logger.debug("Swapped to resume run", { + taskId, + previousRunId, + newRunId: newRun.id, + }); + }, })); + +// Watch for isPromptPending transitions (true → false) and auto-send the +// next queued message. Uses setTimeout so state is fully settled before +// sendPrompt re-enters the store. +const drainInFlight = new Set(); +useTaskSessionStore.subscribe((state, prev) => { + for (const [runId, session] of Object.entries(state.sessions)) { + const prevSession = prev.sessions[runId]; + if ( + prevSession?.isPromptPending && + !session.isPromptPending && + !session.terminalStatus && + session.messageQueue?.length && + !drainInFlight.has(runId) + ) { + drainInFlight.add(runId); + setTimeout(async () => { + try { + const current = useTaskSessionStore.getState().sessions[runId]; + if (!current?.messageQueue?.length) return; + + const [next, ...rest] = current.messageQueue; + useTaskSessionStore.setState((s) => { + const sess = s.sessions[runId]; + if (!sess) return s; + return { + sessions: { + ...s.sessions, + [runId]: { + ...sess, + messageQueue: rest.length > 0 ? rest : undefined, + }, + }, + }; + }); + + await useTaskSessionStore.getState().sendPrompt(current.taskId, next); + } catch (err) { + logger.warn("Failed to send queued message", { runId, error: err }); + } finally { + drainInFlight.delete(runId); + } + }, 50); + } + } +}); diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 4c1578a4b..4ee8bb75e 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -51,9 +51,22 @@ export interface SessionNotification { status?: "pending" | "in_progress" | "completed" | "failed" | null; rawInput?: Record; rawOutput?: unknown; + entries?: PlanEntry[]; + _meta?: { + claudeCode?: { + toolName?: string; + parentToolCallId?: string; + }; + }; }; } +export interface PlanEntry { + content: string; + status: "pending" | "in_progress" | "completed"; + priority: string; +} + export interface AcpMessage { type: "acp_message"; direction: "client" | "agent"; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index fb405b1a6..307efca93 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -56,27 +56,10 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { export function convertRawEntriesToEvents( rawEntries: StoredLogEntry[], notifications: SessionNotification[], - taskDescription?: string, ): SessionEvent[] { const events: SessionEvent[] = []; let notificationIdx = 0; - if (taskDescription) { - const startTs = rawEntries[0]?.timestamp - ? new Date(rawEntries[0].timestamp).getTime() - 1 - : Date.now(); - events.push({ - type: "session_update", - ts: startTs, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }); - } - for (const entry of rawEntries) { const ts = entry.timestamp ? new Date(entry.timestamp).getTime() diff --git a/apps/mobile/src/features/tasks/utils/sounds.ts b/apps/mobile/src/features/tasks/utils/sounds.ts new file mode 100644 index 000000000..8460a0cb3 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/sounds.ts @@ -0,0 +1,23 @@ +import { Audio } from "expo-av"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const meepAsset = require("../../../../assets/sounds/meep.mp3"); + +let audioModeConfigured = false; + +export async function playMeepSound(): Promise { + if (!audioModeConfigured) { + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + }); + audioModeConfigured = true; + } + const { sound } = await Audio.Sound.createAsync(meepAsset, { + shouldPlay: true, + }); + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded && status.didJustFinish) { + sound.unloadAsync(); + } + }); +} diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index 3cfeba297..722c3fab9 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -25,6 +25,14 @@ export const POSTHOG_NOTIFICATIONS = { /** Agent finished processing a turn (prompt returned, waiting for next input) */ TURN_COMPLETE: "_posthog/turn_complete", + /** + * Agent has stopped mid-turn and is blocked on a user reply (e.g. a + * question tool invoked via requestPermission). Emitted by permission + * handlers before they block; clients use it to unblock their input UI + * so the user's answer is sent directly instead of being queued. + */ + AWAITING_USER_INPUT: "_posthog/awaiting_user_input", + /** Error occurred during task execution */ ERROR: "_posthog/error", diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index b97ab8fdb..6315b22d8 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -3,6 +3,7 @@ import type { RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; +import { POSTHOG_NOTIFICATIONS } from "../../../acp-extensions"; import { text } from "../../../utils/acp-content"; import type { Logger } from "../../../utils/logger"; import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp"; @@ -276,6 +277,25 @@ async function handleAskUserQuestionTool( input: toolInput, }); + // Tell any attached clients (including log readers like the mobile app) + // that the agent has stopped and is blocked on a user reply. Without this + // signal, clients reading the log-only stream have no reliable way to + // distinguish "tool running" from "tool waiting for a human" — and treat + // replies as queued-while-busy instead of sending them through. + try { + await client.extNotification(POSTHOG_NOTIFICATIONS.AWAITING_USER_INPUT, { + sessionId, + toolCallId: toolUseID, + }); + } catch (err) { + context.logger.warn( + "[AskUserQuestion] Failed to emit awaiting_user_input", + { + error: err, + }, + ); + } + const response = await client.requestPermission({ options, sessionId, @@ -291,10 +311,23 @@ async function handleAskUserQuestionTool( }, }); - if (context.signal?.aborted || response.outcome?.outcome === "cancelled") { + if (context.signal?.aborted) { throw new Error("Tool use aborted"); } + if (response.outcome?.outcome === "cancelled") { + const cancelMessage = ( + response._meta as Record | undefined + )?.message; + return { + behavior: "deny", + message: + typeof cancelMessage === "string" + ? cancelMessage + : "User cancelled the questions", + }; + } + if (response.outcome?.outcome !== "selected") { const customMessage = ( response._meta as Record | undefined diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 69f9dd620..4a4ce0705 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -599,6 +599,38 @@ export class AgentServer { }; } + case "permission_response": { + // Cloud questions are not a blocking permission wait (the cloud + // permission handler returns "cancelled" with a wait hint so + // Claude ends its turn, then resumes on the next user_message). + // So a mobile permission_response for a cloud run is effectively + // a follow-up prompt — forward it through the user_message path + // using the user's answer as prompt text. + const customInput = + typeof params.customInput === "string" ? params.customInput : ""; + const rawAnswers = params.answers; + const answerValues = + rawAnswers && + typeof rawAnswers === "object" && + !Array.isArray(rawAnswers) + ? Object.values(rawAnswers as Record).filter( + (v): v is string => typeof v === "string", + ) + : []; + const answerText = customInput || answerValues.join(", "); + + if (!answerText) { + this.logger.warn("permission_response missing answer content", { + toolCallId: params.toolCallId, + }); + return { stopReason: "cancelled" }; + } + + return await this.executeCommand("user_message", { + content: answerText, + }); + } + case POSTHOG_NOTIFICATIONS.CANCEL: case "cancel": { this.logger.info("Cancel requested", { @@ -1579,6 +1611,88 @@ ${attributionInstructions} } } + // Interactive sessions (mobile/web): don't auto-approve questions. + // Log the question as in-progress so the client renders it interactively, + // then return cancelled with a wait message so the agent waits for the + // user's reply (same pattern as the Slack relay above). + if ( + mode === "interactive" && + codeToolKind === "question" && + interactionOrigin !== "slack" && + params.toolCall?._meta + ) { + const questionMeta = params.toolCall._meta; + const toolCallId = `question-${Date.now()}`; + + const questionNotification = { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + title: "AskUserQuestion", + toolCallId, + status: "in_progress", + rawInput: questionMeta, + }, + }, + }; + + this.broadcastEvent({ + type: "notification", + timestamp: new Date().toISOString(), + notification: questionNotification, + }); + + this.session?.logWriter.appendRawLine( + payload.run_id, + JSON.stringify(questionNotification), + ); + + return { + outcome: { outcome: "cancelled" as const }, + _meta: { + message: + "This question has been sent to the user's device. " + + "The user will reply with their selection. Do NOT re-ask the question or pick an answer yourself. " + + "Wait for the user's reply.", + }, + }; + } + + // Background mode or non-question permissions: log as completed and auto-approve + if (codeToolKind === "question" && params.toolCall?._meta) { + const questionMeta = params.toolCall._meta; + const toolCallId = `question-${Date.now()}`; + const selectedOption = allowOption?.name ?? "Auto-approved"; + + const questionNotification = { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + title: "AskUserQuestion", + toolCallId, + status: "completed", + rawInput: questionMeta, + rawOutput: { answer: selectedOption }, + }, + }, + }; + + this.broadcastEvent({ + type: "notification", + timestamp: new Date().toISOString(), + notification: questionNotification, + }); + + this.session?.logWriter.appendRawLine( + payload.run_id, + JSON.stringify(questionNotification), + ); + } + // Relay permission requests to the desktop app when: // - Questions: always relay (need human answers regardless of mode) // - Plan approvals: always relay @@ -1953,18 +2067,27 @@ ${attributionInstructions} private broadcastTurnComplete(stopReason: string): void { if (!this.session) return; + const notification = { + jsonrpc: "2.0" as const, + method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE, + params: { + sessionId: this.session.acpSessionId, + stopReason, + }, + }; + this.broadcastEvent({ type: "notification", timestamp: new Date().toISOString(), - notification: { - jsonrpc: "2.0", - method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE, - params: { - sessionId: this.session.acpSessionId, - stopReason, - }, - }, + notification, }); + + // Persist to S3 log so mobile clients (which poll S3) can detect + // turn completion and trigger "your turn" indicators / sounds. + this.session.logWriter.appendRawLine( + this.session.payload.run_id, + JSON.stringify(notification), + ); } private broadcastEvent(event: Record): void { diff --git a/packages/agent/src/server/question-relay.test.ts b/packages/agent/src/server/question-relay.test.ts index e3865e309..d56233ecf 100644 --- a/packages/agent/src/server/question-relay.test.ts +++ b/packages/agent/src/server/question-relay.test.ts @@ -225,7 +225,7 @@ describe("Question relay", () => { delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); - it("auto-approves question tools (no Slack relay)", async () => { + it("returns cancelled with wait message for interactive question tools", async () => { const client = server.createCloudClient(TEST_PAYLOAD); const result = await client.requestPermission({ @@ -233,7 +233,10 @@ describe("Question relay", () => { toolCall: { _meta: QUESTION_META }, }); - expect(result.outcome.outcome).toBe("selected"); + expect(result.outcome.outcome).toBe("cancelled"); + expect(result._meta?.message).toContain( + "sent to the user's device", + ); }); it("keeps auto-approving permissions after SSE send failures", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2364f028..7800234b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,9 @@ importers: expo-secure-store: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33) + expo-speech-recognition: + specifier: ^3.1.2 + version: 3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-splash-screen: specifier: ~31.0.12 version: 31.0.13(expo@54.0.33) @@ -7012,6 +7015,13 @@ packages: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} + expo-speech-recognition@3.1.2: + resolution: {integrity: sha512-yaXy+6w218Urdshits2KsfLjXNCnGNlXzUxEP4BVehKEbiIPAeUKBzuicCeELU5H2zTLwL9u+RjbFAUom4LiYQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-splash-screen@31.0.13: resolution: {integrity: sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==} peerDependencies: @@ -18802,6 +18812,12 @@ snapshots: expo-server@1.0.5: {} + expo-speech-recognition@3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-splash-screen@31.0.13(expo@54.0.33): dependencies: '@expo/prebuild-config': 54.0.8(expo@54.0.33) From 5fe491b4e65536b28de0632ba5cb3fbd857f159e Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 09:25:30 +0100 Subject: [PATCH 03/39] feat: Hide the posthog AI tab by default (unless enabled in settings) --- apps/mobile/src/app/(tabs)/_layout.tsx | 30 +++++++++-------- apps/mobile/src/app/(tabs)/index.tsx | 8 ++++- apps/mobile/src/app/(tabs)/settings.tsx | 23 +++++++++++++ apps/mobile/src/app/(tabs)/tasks.tsx | 6 ++-- apps/mobile/src/app/_layout.tsx | 44 ++++++++++++++----------- 5 files changed, 76 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 9641fa233..1e4bc6033 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,9 +1,11 @@ import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; import { DynamicColorIOS, Platform } from "react-native"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { useThemeColors } from "@/lib/theme"; export default function TabsLayout() { const themeColors = useThemeColors(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); // Dynamic colors for liquid glass effect on iOS const dynamicTextColor = @@ -30,21 +32,23 @@ export default function TabsLayout() { tintColor={dynamicTintColor} minimizeBehavior="onScrollDown" > - {/* Conversations - First Tab (default landing) */} - - - - + {/* Conversations - Chats tab, hidden by default to focus on Code */} + {aiChatEnabled && ( + + + + + )} - {/* Tasks Tab */} + {/* Code tab (task list for PostHog Code) */} - + s.aiChatEnabled); + + if (!aiChatEnabled) { + return ; + } const handleConversationPress = (conversation: ConversationDetail) => { router.push(`/chat/${conversation.id}`); diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 5c3907b3f..c3597430d 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -2,15 +2,19 @@ import { router } from "expo-router"; import { Linking, ScrollView, + Switch, Text, TouchableOpacity, View, } from "react-native"; import { useAuthStore, useUserQuery } from "@/features/auth"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; export default function SettingsScreen() { const { logout, cloudRegion, getCloudUrlFromRegion } = useAuthStore(); const { data: userData } = useUserQuery(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); + const setAiChatEnabled = usePreferencesStore((s) => s.setAiChatEnabled); const handleLogout = async () => { await logout(); @@ -88,6 +92,25 @@ export default function SettingsScreen() { + {/* Labs */} + + Labs + + Experimental features + + + + + PostHog AI chat + + + Show the Chats tab for PostHog AI conversations + + + + + + {/* All Settings Button */} - Tasks - Your PostHog tasks + Code + + Your PostHog Code sessions + s.aiChatEnabled); const themeColors = useThemeColors(); useScreenTracking(); @@ -47,25 +49,29 @@ function RootLayoutNav() { - {/* Chat routes - regular stack navigation */} - - + {/* Chat routes - only registered when AI chat feature is enabled */} + {aiChatEnabled && ( + <> + + + + )} {/* Task routes - modal presentation */} Date: Wed, 15 Apr 2026 09:27:17 +0100 Subject: [PATCH 04/39] feat: Better represent the other AI types --- .../tasks/components/TaskSessionView.tsx | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 9df96c4b6..7f5d71261 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -26,7 +26,7 @@ interface ToolData { interface ParsedMessage { id: string; - type: "user" | "agent" | "tool"; + type: "user" | "agent" | "thought" | "tool"; content: string; toolData?: ToolData; } @@ -49,7 +49,7 @@ function mapToolStatus( } function parseSessionNotification(notification: SessionNotification): { - type: "user" | "agent" | "tool" | "tool_update"; + type: "user" | "agent" | "thought" | "tool" | "tool_update"; content?: string; toolData?: ToolData; } | null { @@ -70,6 +70,12 @@ function parseSessionNotification(notification: SessionNotification): { } return null; } + case "agent_thought_chunk": { + if (update.content?.type === "text") { + return { type: "thought", content: update.content.text }; + } + return null; + } case "tool_call": { return { type: "tool", @@ -101,7 +107,9 @@ function parseSessionNotification(notification: SessionNotification): { function processEvents(events: SessionEvent[]): ParsedMessage[] { const messages: ParsedMessage[] = []; let pendingAgentText = ""; + let pendingThoughtText = ""; let agentMessageCount = 0; + let thoughtMessageCount = 0; const toolMessages = new Map(); const flushAgentText = () => { @@ -114,6 +122,21 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { pendingAgentText = ""; }; + const flushThoughtText = () => { + if (!pendingThoughtText) return; + messages.push({ + id: `thought-${thoughtMessageCount++}`, + type: "thought", + content: pendingThoughtText, + }); + pendingThoughtText = ""; + }; + + const flushPending = () => { + flushThoughtText(); + flushAgentText(); + }; + for (const event of events) { if (event.type !== "session_update") continue; @@ -122,7 +145,7 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { switch (parsed.type) { case "user": - flushAgentText(); + flushPending(); messages.push({ id: `user-${event.ts}`, type: "user", @@ -130,10 +153,15 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { }); break; case "agent": + flushThoughtText(); pendingAgentText += parsed.content ?? ""; break; - case "tool": + case "thought": flushAgentText(); + pendingThoughtText += parsed.content ?? ""; + break; + case "tool": + flushPending(); if (parsed.toolData) { const msg: ParsedMessage = { id: `tool-${parsed.toolData.toolCallId}`, @@ -157,7 +185,7 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { } } - flushAgentText(); + flushPending(); return messages; } @@ -179,6 +207,14 @@ export function TaskSessionView({ return ( ); + case "thought": + return ( + + ); case "tool": return item.toolData ? ( Date: Wed, 15 Apr 2026 09:27:37 +0100 Subject: [PATCH 05/39] feat: Add pref store --- .../preferences/stores/preferencesStore.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/mobile/src/features/preferences/stores/preferencesStore.ts diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts new file mode 100644 index 000000000..557c110a9 --- /dev/null +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -0,0 +1,22 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface PreferencesState { + aiChatEnabled: boolean; + setAiChatEnabled: (enabled: boolean) => void; +} + +export const usePreferencesStore = create()( + persist( + (set) => ({ + aiChatEnabled: false, + setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), + }), + { + name: "posthog-preferences", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ aiChatEnabled: state.aiChatEnabled }), + }, + ), +); From 3b8887026ebd8f6b6f2d31585bb487d3476bd65d Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 10:55:17 +0100 Subject: [PATCH 06/39] WIP COMMIT --- apps/mobile/src/app/task/[id].tsx | 26 ++ apps/mobile/src/app/task/index.tsx | 83 +++-- apps/mobile/src/features/tasks/api.ts | 157 +++++++- .../tasks/components/TaskSessionView.tsx | 33 +- .../features/tasks/stores/taskSessionStore.ts | 341 ++++++++++++++---- 5 files changed, 512 insertions(+), 128 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 35a1aa2d7..f8064ff5c 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -165,6 +165,32 @@ export default function TaskDetailScreen() { className="absolute inset-x-0 bottom-0" style={inputContainerStyle} > + {session?.terminalStatus && ( + + + {session.terminalStatus === "failed" + ? `Run failed${session.lastError ? `: ${session.lastError}` : ""}` + : "Run completed"} + + {session.terminalStatus === "failed" && ( + + Send a message to start a new run. + + )} + + )} diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 42e535e15..1f4aaf2db 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; import { Stack, useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -79,10 +79,17 @@ export default function NewTaskScreen() { const [integrations, setIntegrations] = useState([]); const [repositories, setRepositories] = useState([]); const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearch, setRepoSearch] = useState(""); const [prompt, setPrompt] = useState(""); const [creating, setCreating] = useState(false); const [loadingRepos, setLoadingRepos] = useState(true); + const filteredRepositories = useMemo(() => { + const query = repoSearch.trim().toLowerCase(); + if (!query) return repositories; + return repositories.filter((repo) => repo.toLowerCase().includes(query)); + }, [repositories, repoSearch]); + const loadIntegrations = useCallback(async () => { try { setLoadingRepos(true); @@ -116,14 +123,21 @@ export default function NewTaskScreen() { try { const githubIntegration = integrations.find((i) => i.kind === "github"); + const trimmedPrompt = prompt.trim(); const task = await createTask({ - description: prompt.trim(), - title: prompt.trim().slice(0, 100), + description: trimmedPrompt, + title: trimmedPrompt.slice(0, 100), repository: selectedRepo, github_integration: githubIntegration?.id, }); - await runTaskInCloud(task.id); + // Pass the prompt as pending_user_message so the cloud agent has + // something to process on start — matches how the desktop launches + // new cloud runs. Without this the sandbox starts idle and the UI + // stays stuck on "Thinking...". + await runTaskInCloud(task.id, { + pendingUserMessage: trimmedPrompt, + }); // Navigate to task detail (replaces current modal) router.replace(`/task/${task.id}`); @@ -161,29 +175,50 @@ export default function NewTaskScreen() { ) : ( <> Repository + - item} - renderItem={({ item }) => ( - setSelectedRepo(item)} - className={`border-gray-6 border-b px-3 py-3 ${ - selectedRepo === item ? "bg-accent-3" : "" - }`} - > - + + {repoSearch + ? `No repositories match "${repoSearch}"` + : "No repositories available"} + + + ) : ( + item} + keyboardShouldPersistTaps="handled" + renderItem={({ item }) => ( + setSelectedRepo(item)} + className={`border-gray-6 border-b px-3 py-3 ${ + selectedRepo === item ? "bg-accent-3" : "" }`} > - {item} - - - )} - /> + + {item} + + + )} + /> + )} Task description diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 10f023349..66d2999b5 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -160,16 +160,52 @@ export async function deleteTask(taskId: string): Promise { } } -export async function runTaskInCloud(taskId: string): Promise { +export interface RunTaskInCloudOptions { + branch?: string | null; + resumeFromRunId?: string; + pendingUserMessage?: string; + mode?: "interactive" | "background"; +} + +export async function runTaskInCloud( + taskId: string, + options?: RunTaskInCloudOptions, +): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); const headers = getHeaders(); + // Only serialize a body when we have options to send. Sending an empty + // or minimal body on the initial run historically changed backend + // behavior, so we preserve the "no body" path for the common case. + const hasOptions = + !!options && + (options.branch !== undefined || + options.resumeFromRunId !== undefined || + options.pendingUserMessage !== undefined || + options.mode !== undefined); + + let body: string | undefined; + if (hasOptions) { + const payload: Record = { + mode: options?.mode ?? "interactive", + }; + if (options?.branch) payload.branch = options.branch; + if (options?.resumeFromRunId) { + payload.resume_from_run_id = options.resumeFromRunId; + } + if (options?.pendingUserMessage) { + payload.pending_user_message = options.pendingUserMessage; + } + body = JSON.stringify(payload); + } + const response = await fetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`, { method: "POST", headers, + body, }, ); @@ -228,6 +264,103 @@ export async function appendTaskRunLog( ); } +/** + * Structured error thrown by `sendCloudCommand`. Exposes the HTTP status and + * the backend error payload so callers can branch on specific failure modes + * (e.g. "No active sandbox for this task run" → trigger a resume flow). + */ +export class CloudCommandError extends Error { + readonly status: number; + readonly backendError: string | null; + readonly method: string; + + constructor( + method: string, + status: number, + backendError: string | null, + message: string, + ) { + super(message); + this.name = "CloudCommandError"; + this.method = method; + this.status = status; + this.backendError = backendError; + } + + /** True when the cloud sandbox for this run has terminated. */ + isSandboxInactive(): boolean { + return !!this.backendError?.includes("No active sandbox"); + } +} + +/** + * Sends a JSON-RPC command to a running cloud task. This is the correct path + * for delivering follow-up user prompts to the agent — it gets translated into + * `session/prompt` on the agent side. Note: `appendTaskRunLog` only writes to + * S3 for display; it does NOT notify the agent. + */ +export async function sendCloudCommand( + taskId: string, + runId: string, + method: string, + params: Record = {}, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const body = { + jsonrpc: "2.0", + method, + params, + id: `posthog-mobile-${Date.now()}`, + }; + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/command/`, + { + method: "POST", + headers, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + let backendError: string | null = null; + try { + const parsed = JSON.parse(text); + backendError = + typeof parsed?.error === "string" + ? parsed.error + : (parsed?.error?.message ?? null); + } catch { + backendError = text || null; + } + throw new CloudCommandError( + method, + response.status, + backendError, + `Cloud command '${method}' failed: ${response.status} ${response.statusText} ${text}`, + ); + } + + const data = await response.json(); + if (data?.error) { + const message = + typeof data.error === "string" + ? data.error + : (data.error.message ?? JSON.stringify(data.error)); + throw new CloudCommandError( + method, + 200, + message, + `Cloud command '${method}' error: ${message}`, + ); + } + return data?.result; +} + export async function fetchS3Logs(logUrl: string): Promise { return withRetry( async () => { @@ -281,17 +414,13 @@ export async function getGithubRepositories( } const data = await response.json(); - - const integrations = await getIntegrations(); - const integration = integrations.find((i) => i.id === integrationId); - const organization = - integration?.display_name || - integration?.config?.account?.login || - "unknown"; - - const repoNames = data.repositories ?? data.results ?? data ?? []; - return repoNames.map( - (repoName: string) => - `${organization.toLowerCase()}/${repoName.toLowerCase()}`, - ); + const repos: Array = + data.repositories ?? data.results ?? data ?? []; + + return repos + .map((repo) => { + if (typeof repo === "string") return repo.toLowerCase(); + return (repo.full_name ?? repo.name ?? "").toLowerCase(); + }) + .filter((name) => name.length > 0); } diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 7f5d71261..79002dc25 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -60,7 +60,12 @@ function parseSessionNotification(notification: SessionNotification): { switch (update.sessionUpdate) { case "user_message_chunk": - case "agent_message_chunk": { + case "agent_message_chunk": + // `agent_message` is the aggregated final message emitted by the server + // once a response is complete; the desktop treats it the same as a + // streaming chunk. Without this case the final answer is silently + // dropped and the spinner stays on forever. + case "agent_message": { if (update.content?.type === "text") { return { type: @@ -110,6 +115,7 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { let pendingThoughtText = ""; let agentMessageCount = 0; let thoughtMessageCount = 0; + let userMessageCount = 0; const toolMessages = new Map(); const flushAgentText = () => { @@ -147,7 +153,7 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { case "user": flushPending(); messages.push({ - id: `user-${event.ts}`, + id: `user-${userMessageCount++}`, type: "user", content: parsed.content ?? "", }); @@ -163,14 +169,21 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { case "tool": flushPending(); if (parsed.toolData) { - const msg: ParsedMessage = { - id: `tool-${parsed.toolData.toolCallId}`, - type: "tool", - content: "", - toolData: parsed.toolData, - }; - toolMessages.set(parsed.toolData.toolCallId, msg); - messages.push(msg); + const existing = toolMessages.get(parsed.toolData.toolCallId); + if (existing?.toolData) { + // Duplicate tool_call — refresh fields on the existing message + // in place instead of pushing a second entry with a colliding key. + existing.toolData = { ...existing.toolData, ...parsed.toolData }; + } else { + const msg: ParsedMessage = { + id: `tool-${parsed.toolData.toolCallId}`, + type: "tool", + content: "", + toolData: parsed.toolData, + }; + toolMessages.set(parsed.toolData.toolCallId, msg); + messages.push(msg); + } } break; case "tool_update": diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index a11ac1383..44a57310d 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,12 +1,14 @@ import { create } from "zustand"; import { logger } from "@/lib/logger"; -import { appendTaskRunLog, fetchS3Logs, runTaskInCloud } from "../api"; -import type { - SessionEvent, - SessionNotification, - StoredLogEntry, - Task, -} from "../types"; +import { + CloudCommandError, + fetchS3Logs, + getTask, + getTaskRun, + runTaskInCloud, + sendCloudCommand, +} from "../api"; +import type { SessionEvent, SessionNotification, Task } from "../types"; import { convertRawEntriesToEvents, parseSessionLogs, @@ -23,6 +25,13 @@ export interface TaskSession { logUrl: string; processedLineCount: number; processedHashes?: Set; + // Content of user prompts echoed locally (before the agent writes them to + // the log). Used by polling to dedup the canonical copy against the echo. + localUserEchoes?: Set; + // Terminal backend status for this run, populated by the status-check + // poller so the UI can surface "Run failed" / "Run completed". + terminalStatus?: "failed" | "completed"; + lastError?: string | null; } interface TaskSessionStore { @@ -37,10 +46,19 @@ interface TaskSessionStore { _handleEvent: (taskRunId: string, event: SessionEvent) => void; _startCloudPolling: (taskRunId: string, logUrl: string) => void; _stopCloudPolling: (taskRunId: string) => void; + _resumeCloudRun: ( + taskId: string, + previousRunId: string, + prompt: string, + ) => Promise; } const cloudPollers = new Map>(); const connectAttempts = new Set(); +// Tick counts per task run used to throttle backend task-run status polling. +const pollTicks = new Map(); +// How many S3 polling ticks between each backend task-run status check. +const STATUS_CHECK_TICK_INTERVAL = 5; export const useTaskSessionStore = create((set, get) => ({ sessions: {}, @@ -121,6 +139,7 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Loaded cloud historical logs", { notifications: notifications.length, rawEntries: rawEntries.length, + backendStatus: task.latest_run?.status, }); const historicalEvents = convertRawEntriesToEvents( @@ -129,21 +148,19 @@ export const useTaskSessionStore = create((set, get) => ({ taskDescription, ); - // Check if agent is still processing by looking at the last entry - // If the last non-client entry is a user message, agent is likely still working - const lastAgentEntry = [...rawEntries] - .reverse() - .find((e) => e.direction !== "client"); - // biome-ignore lint/suspicious/noExplicitAny: Entry structure varies - const lastUpdate = (lastAgentEntry?.notification as any)?.params?.update - ?.sessionUpdate; - const isAgentResponding = - lastUpdate === "agent_message_chunk" || - lastUpdate === "agent_thought_chunk" || - lastUpdate === "tool_call" || - lastUpdate === "tool_call_update"; - // If we have entries but the last one isn't an agent response, agent may still be processing - const isPromptPending = rawEntries.length > 0 && !isAgentResponding; + // Source of truth for "is the agent still working" is the backend run + // status, not a heuristic over the log shape. A completed/failed run + // must NOT show the "Thinking..." indicator even if the last log entry + // isn't a recognized agent-response type. + const backendStatus = task.latest_run?.status; + const isTerminal = + backendStatus === "completed" || backendStatus === "failed"; + const terminalStatus: "completed" | "failed" | undefined = isTerminal + ? (backendStatus as "completed" | "failed") + : undefined; + const lastError = isTerminal + ? (task.latest_run?.error_message ?? null) + : null; set((state) => ({ sessions: { @@ -153,15 +170,22 @@ export const useTaskSessionStore = create((set, get) => ({ taskId, events: historicalEvents, status: "connected", - isPromptPending, + isPromptPending: !isTerminal, logUrl: latestRunLogUrl, processedLineCount: rawEntries.length, + terminalStatus, + lastError, }, }, })); get()._startCloudPolling(latestRunId, latestRunLogUrl); - logger.debug("Connected to cloud session", { taskId, latestRunId }); + logger.debug("Connected to cloud session", { + taskId, + latestRunId, + backendStatus, + isTerminal, + }); } catch (error) { logger.error("Failed to connect to task", error); } finally { @@ -188,67 +212,93 @@ export const useTaskSessionStore = create((set, get) => ({ throw new Error("No active session for task"); } - const notification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", + // Local echo for immediate UX feedback — polling will re-surface the + // canonical copy once the agent writes it to the log; any duplicate is + // removed by content-based dedup in the polling loop below. + const ts = Date.now(); + const userEvent: SessionEvent = { + type: "session_update", + ts, notification: { - method: "session/update", - params: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: prompt }, - }, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: prompt }, }, }, }; - await appendTaskRunLog(taskId, session.taskRunId, [notification]); - logger.debug("Sent cloud message via S3", { - taskId, - runId: session.taskRunId, + set((state) => { + const current = state.sessions[session.taskRunId]; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + }, + }, + }; }); - const ts = Date.now(); - const userEvent: SessionEvent = { - type: "session_update", - ts, - notification: notification.notification?.params as SessionNotification, - }; + try { + await sendCloudCommand(taskId, session.taskRunId, "user_message", { + content: prompt, + }); + logger.debug("Sent cloud command user_message", { + taskId, + runId: session.taskRunId, + }); + } catch (err) { + // Sandbox for this run has shut down — create a resume run on the + // backend and swap the local session to the new run id. + let rollbackError: unknown = err; + if (err instanceof CloudCommandError && err.isSandboxInactive()) { + logger.info("Sandbox inactive, creating resume run", { + taskId, + previousRunId: session.taskRunId, + }); + try { + await get()._resumeCloudRun(taskId, session.taskRunId, prompt); + return; + } catch (resumeErr) { + logger.error("Failed to resume cloud run", resumeErr); + rollbackError = resumeErr; + } + } - set((state) => ({ - sessions: { - ...state.sessions, - [session.taskRunId]: { - ...state.sessions[session.taskRunId], - events: [...state.sessions[session.taskRunId].events, userEvent], - processedLineCount: - (state.sessions[session.taskRunId].processedLineCount ?? 0) + 1, - isPromptPending: true, - }, - }, - })); + // Roll back the local echo + pending state so the user can retry. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw rollbackError; + } }, cancelPrompt: async (taskId: string) => { const session = get().getSessionForTask(taskId); if (!session) return false; - const cancelNotification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", - notification: { - method: "session/cancel", - params: { - sessionId: session.taskRunId, - }, - }, - }; - try { - await appendTaskRunLog(taskId, session.taskRunId, [cancelNotification]); - logger.debug("Sent cancel request via S3", { + await sendCloudCommand(taskId, session.taskRunId, "cancel"); + logger.debug("Sent cancel command", { taskId, runId: session.taskRunId, }); @@ -302,6 +352,48 @@ export const useTaskSessionStore = create((set, get) => ({ return; } + // Periodically check the backend task-run status as a safety net + // for runs that never write anything to the S3 log (e.g. failed + // pre-agent-start). This prevents "stuck on Thinking..." forever. + const tick = (pollTicks.get(taskRunId) ?? 0) + 1; + pollTicks.set(taskRunId, tick); + if (tick % STATUS_CHECK_TICK_INTERVAL === 0) { + try { + const run = await getTaskRun(session.taskId, taskRunId); + logger.debug("Status check", { + taskRunId, + status: run.status, + error: run.error_message, + }); + if (run.status === "failed" || run.status === "completed") { + logger.debug("Backend run reached terminal status", { + taskRunId, + status: run.status, + error: run.error_message, + }); + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + isPromptPending: false, + terminalStatus: run.status as "failed" | "completed", + lastError: run.error_message, + }, + }, + }; + }); + } + } catch (statusErr) { + logger.warn("Failed to fetch task run status", { + error: statusErr, + }); + } + } + const text = await fetchS3Logs(logUrl); if (!text) return; @@ -310,7 +402,13 @@ export const useTaskSessionStore = create((set, get) => ({ if (lines.length > processedCount) { const newLines = lines.slice(processedCount); + logger.debug("Poll picked up new log lines", { + taskRunId, + newLineCount: newLines.length, + totalLines: lines.length, + }); const currentHashes = new Set(session.processedHashes ?? []); + const remainingLocalEchoes = new Set(session.localUserEchoes ?? []); let receivedAgentMessage = false; @@ -327,11 +425,6 @@ export const useTaskSessionStore = create((set, get) => ({ } currentHashes.add(hash); - const isClientMessage = entry.direction === "client"; - if (isClientMessage) { - continue; - } - const acpEvent: SessionEvent = { type: "acp_message", direction: entry.direction ?? "agent", @@ -340,24 +433,54 @@ export const useTaskSessionStore = create((set, get) => ({ }; get()._handleEvent(taskRunId, acpEvent); + // Terminal notifications from the agent — when the run + // completes or errors, clear the pending indicator so the + // UI doesn't stay stuck on "Thinking...". + if ( + entry.type === "notification" && + (entry.notification?.method === "_posthog/task_complete" || + entry.notification?.method === "_posthog/error") + ) { + receivedAgentMessage = true; + logger.debug("Received terminal notification", { + taskRunId, + method: entry.notification.method, + }); + } + if ( entry.type === "notification" && entry.notification?.method === "session/update" && entry.notification?.params ) { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + // If this is a user_message_chunk that matches a locally + // echoed prompt, consume the echo and skip — prevents the + // prompt from rendering twice. + if (sessionUpdate === "user_message_chunk") { + const text = params?.update?.content?.text; + if (text && remainingLocalEchoes.has(text)) { + remainingLocalEchoes.delete(text); + continue; + } + } + const sessionUpdateEvent: SessionEvent = { type: "session_update", ts, - notification: entry.notification - .params as SessionNotification, + notification: params, }; get()._handleEvent(taskRunId, sessionUpdateEvent); - // Check if this is an agent message - means agent is responding - const sessionUpdate = - entry.notification?.params?.update?.sessionUpdate; + // Check if this is an agent message - means agent is + // responding. `agent_message` is the aggregated final + // message emitted by the server once a response completes + // (as opposed to streaming `agent_message_chunk` frames). if ( sessionUpdate === "agent_message_chunk" || + sessionUpdate === "agent_message" || sessionUpdate === "agent_thought_chunk" ) { receivedAgentMessage = true; @@ -375,6 +498,7 @@ export const useTaskSessionStore = create((set, get) => ({ ...state.sessions[taskRunId], processedLineCount: lines.length, processedHashes: currentHashes, + localUserEchoes: remainingLocalEchoes, // Clear pending state when we receive agent response isPromptPending: receivedAgentMessage ? false @@ -398,7 +522,64 @@ export const useTaskSessionStore = create((set, get) => ({ if (interval) { clearInterval(interval); cloudPollers.delete(taskRunId); + pollTicks.delete(taskRunId); logger.debug("Stopped cloud S3 polling", { taskRunId }); } }, + + _resumeCloudRun: async ( + taskId: string, + previousRunId: string, + prompt: string, + ) => { + // Fetch the latest task to pick up the branch the previous run was using — + // otherwise the backend would create a new branch and we'd lose working + // tree context. + const freshTask = await getTask(taskId); + const previousBranch = freshTask.latest_run?.branch ?? null; + + const updatedTask = await runTaskInCloud(taskId, { + branch: previousBranch, + resumeFromRunId: previousRunId, + pendingUserMessage: prompt, + }); + + const newRun = updatedTask.latest_run; + if (!newRun?.id || !newRun.log_url) { + throw new Error("Resume run was created but has no id or log_url"); + } + + // Stop polling the dead run and swap the session over to the new run id. + // Read the CURRENT session state to preserve the local echo that was + // just added in sendPrompt (the captured `session` variable in the + // caller is stale). + get()._stopCloudPolling(previousRunId); + + set((state) => { + const previousSession = state.sessions[previousRunId]; + if (!previousSession) return state; + const { [previousRunId]: _old, ...rest } = state.sessions; + return { + sessions: { + ...rest, + [newRun.id]: { + ...previousSession, + taskRunId: newRun.id, + logUrl: newRun.log_url, + status: "connected", + isPromptPending: true, + processedLineCount: 0, + processedHashes: new Set(), + }, + }, + }; + }); + + get()._startCloudPolling(newRun.id, newRun.log_url); + logger.debug("Swapped to resume run", { + taskId, + previousRunId, + newRunId: newRun.id, + }); + }, })); From c72eff76e9c969f13e14db273f1c46581c0ab49b Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 10:57:37 +0100 Subject: [PATCH 07/39] feat: Show cloud / local rns --- apps/mobile/src/app/task/[id].tsx | 21 +++++++++++++++++++ .../features/tasks/components/TaskItem.tsx | 15 ++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index f8064ff5c..047ef650e 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -134,6 +134,8 @@ export default function TaskDetailScreen() { ); } + const environment = task.latest_run?.environment; + return ( <> ( + + + {environment === "cloud" ? "Cloud" : "Local"} + + + ) + : undefined, }} /> diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index 6b92195d7..e067abda7 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -36,7 +36,7 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { const prUrl = task.latest_run?.output?.pr_url as string | undefined; const hasPR = !!prUrl; const status = hasPR ? "completed" : task.latest_run?.status || "backlog"; - const isCloudTask = task.latest_run?.environment === "cloud"; + const environment = task.latest_run?.environment; const statusColors = statusColorMap[status] || statusColorMap.backlog; @@ -56,10 +56,15 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { - {/* Cloud indicator */} - {isCloudTask && ( - - ☁️ + {/* Environment badge */} + {environment === "cloud" && ( + + Cloud + + )} + {environment === "local" && ( + + Local )} From 830db27b906865ae6c100bcfda9e99440b363fad Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 17:50:18 +0100 Subject: [PATCH 08/39] WIP COMMIT --- apps/mobile/app.json | 10 +- apps/mobile/assets/sounds/meep.mp3 | Bin 0 -> 7941 bytes apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/settings.tsx | 13 + apps/mobile/src/app/task/[id].tsx | 78 +- .../features/chat/components/AgentMessage.tsx | 5 +- .../src/features/chat/components/Composer.tsx | 136 ++- .../features/chat/components/MarkdownText.tsx | 214 +++++ .../features/chat/components/ToolMessage.tsx | 798 +++++++++++++++++- .../features/chat/hooks/useVoiceRecording.ts | 212 +++-- apps/mobile/src/features/chat/index.ts | 2 +- .../preferences/stores/preferencesStore.ts | 9 +- apps/mobile/src/features/tasks/api.ts | 6 +- .../tasks/components/PlanStatusBar.tsx | 88 ++ .../tasks/components/QuestionCard.tsx | 341 ++++++++ .../tasks/components/TaskSessionView.tsx | 512 +++++++++-- .../features/tasks/stores/taskSessionStore.ts | 195 +++-- apps/mobile/src/features/tasks/types.ts | 13 + .../features/tasks/utils/parseSessionLogs.ts | 8 +- .../mobile/src/features/tasks/utils/sounds.ts | 23 + .../claude/permissions/permission-handlers.ts | 15 +- packages/agent/src/server/agent-server.ts | 107 ++- .../agent/src/server/question-relay.test.ts | 7 +- pnpm-lock.yaml | 16 + 24 files changed, 2398 insertions(+), 411 deletions(-) create mode 100644 apps/mobile/assets/sounds/meep.mp3 create mode 100644 apps/mobile/src/features/chat/components/MarkdownText.tsx create mode 100644 apps/mobile/src/features/tasks/components/PlanStatusBar.tsx create mode 100644 apps/mobile/src/features/tasks/components/QuestionCard.tsx create mode 100644 apps/mobile/src/features/tasks/utils/sounds.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index cb58a4bfc..bfc669988 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -19,6 +19,7 @@ "bundleIdentifier": "com.posthog.mobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", + "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", "ITSAppUsesNonExemptEncryption": false } }, @@ -152,7 +153,14 @@ } } ], - "expo-localization" + "expo-localization", + [ + "expo-speech-recognition", + { + "microphonePermission": "Allow PostHog to use your microphone for voice-to-text input", + "speechRecognitionPermission": "Allow PostHog to transcribe your voice input on-device" + } + ] ], "extra": { "router": {}, diff --git a/apps/mobile/assets/sounds/meep.mp3 b/apps/mobile/assets/sounds/meep.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fd7b4cf7e0afaf5cd9ffb0f03afff9c5adee6ace GIT binary patch literal 7941 zcmchccTiN%p2hDBL(W4E;*bXfBn&yozyL#(EK$ih3kZlZgJcN<3@{|gISGP*h?0|_ z@WUi$nk>3!G z{7HPK5d20ASnszGWI33P_x#*Sou$7UT(YLAPCSM~TPEm#8x}Aoosh&E;BMICRB)nB zd{|Fzq3kS0Xg0whU5dPkbXvw%eNTaO0z4&YqJj$?HI(mm(Q$EZBC-F5{pnQr0QRtV zWlzCHTSjkKul9MbcrNS#Y*z)R%tP}#oGg>h2u}S+3E_`46UKtfCzo7mD8;TTwISzB&e+Oj~8oJ zJK!4SGVCR&i23v>j2OzhUJMQyqk?%g78QMMGt>1cTqla$mJs$;Y^hC8d6>|{`if)M zc-v4p^u|@ycBXIOsntc%b<)&DhJ+m46x!}&X^`Efhi`?2M=UC5AtNjq}A&nT_jSGb`G(YL?)5uv+Zucxx{QlHp#>d31Ff=>*CS&E>Ys%@l0Vz$^ zqHBTzu{@KN@!4a|44N<6o|o_6lBGz^W_yl@Xt#MAvKnqxF?;Gg_F=Z&6m!O-dnbJ! z8oe&@Sd&rUcu{cBum*wv&QbVNx=5NE8Zp<5W>wV70%K7lLt~4Q${hF7Du55Oai|7O$bWZNS)*RIz^s`WGSQ!vpZ(8myPS+>!WYqZ}}r0D|bz*#>gV zjw0s_$Fc8)i7_%sH0+E}9+_+ipKA6iRIy4NZYPnK)znBpG-jEX=USzc>l!BJi&)fL zI<0GXaZBkd&qJZ%$)vpkabh+i3^7R(w_y+S+ZfYX1O1?HQ%LF+{G8#Z%H7IE!0E2b z-RD!Uo~`pVF@L!Zs%>k%1mPy z4(g(!j)iDpI1~9K`8&x)S~sKNJ;=${PU$mv(_>~LKObPt>X7O=u4|NmqFY?WI^&a? zWp=jG`RMP1Z_j@*`J9Ree1!BzVd=hJ6eA@Z!65N2+UkH_Ok7A4KVB~Gi*&N*qpOD~ zmhcMpmF9m21pGya=|WN5$Nt*Lfgu0R^lOVhQVb*V(b??NA;PkPX=2F11Tr$aM}>zz z#@!rdISFMK=vzVISRi5|qG?17vqBP=o<_}tG9(OvVq>fuQ zMh|nGT~w)Jjh?r^l!*;8Jyn^0RrXzxG2KQl{<(Ku8XR7Jxr;x!s8#L%1-HXOIK#c3 zc&dqnMig2N&3eBtU!$WD40aXA5WBuLH z(}j#)v!DCS7Hy{vBoSWuMj}aiwkR-E*2*tyMqH#6$@B5O_0yvG*NthzHH5xlQ<``K zlnypN3V4)HYGPnCSXRsb>-7R1j$R;Hl7=}VyXoCik~n`>2e-GSqifdW8}9mhY56hh zq3)#)#~<4Vto@E(O$<|Lz32X10SW#3w5r62k41V}!j+|GAi?fdSr#AJ!vc|=iSS{O zdjh9y986f+*`Gc3yt*c(IQs!}J66Tl#tsx7a8>}V3c&u%`hQn{5n@qR6rm|QkGv3c z-dgSqu^-~5i0UJgPi8)b9vkQPeUM$DDHV#kL&F^s(_`jqf z=2pVP8^wWbd5UjxUv+a7%=)EtHA@-->7D#YKwtz*T`T)E%j{Bt%6_lE(A@Qan&><2 zJ@k2$;@l`(E@Uv9=E8II(00Wgj>fnmreUJPZO(;3qNR3`9_G41nc~qFx zXKfkQU-;z?8RJ?IGY#WAJQ58O^&3|%FVD+}1gLXd$LRnVCp!*nsMO0-dDK7J55T+p zsJc|qL2BZAG4Jw3U_BucG#thgRp$(ubs7fTBQ7Mu%<6L=UooFy=NEV-P}^X3^g)70Ie%T) z-4poQ@f-uEhcnJTN~=DyZ@vITQ?ALvVW$Z z(GjLW#d5z+wGx`p?9I3NU*z*QV1E&s6H*kbyXU|FsVG}pQI-QRM51VW`wHg9vyYZZ zg+=tjQh68Th3TX~yJ?xdoO7Q1J+^G0tHZAz9uk4?3NAmrn)kwrmxipv;8V>V5V@9f zD)EU}iZKkuDNQ}=XI#JB2h{FLfg{{bUMIN5JbnFZoz!hBd=jMabyhVF)F2;`PjW)Fgv?>XYdTYqHrIgE z^|t{YY5mj{5to;`LKAgXL?nDMFdVw4gm2|DdH|M40U?WIU|_f_1mf#XK@BD8e0&x+ zp)DZsc2;ikPRF!Vcxhu)5Hr^l?=_3H9v;b-c`{hX`O}ap79SlMI(Czx%#CGThX5B! z7X<1}?WC88=h<}sDLO2?;E9DR_iLBi(ND#D9PeGXU!JZz$)Mz^*NmfwAuP|`7w+V^ zY8z3dgKKYKH;Lc_oP~Nsc~^!5&bX1cZ}Bbb?%Y?j7DVuQTiuR1Nf4U5y!^T6Kzz@Q z=>d71P-USVh#LSf|DmV92xZDEN{emn_YzgOu&oHR0iZwM*AWyWymVAK*f_2 z*y~mavOhGZP>fFZDN|1udp};T%d~8j-Muv-a`s!(L0OjhdXnrbscPNa{^Zfv7v(Qs zWC|xxoG{%S-a*(a8a{nMJ0+q%n%QLaGM1In$ibU$w#?2WbG%4aU+CTL?Ip%C>k+I@ zP+%d3u}^Y~tjEMVxEF-6B(Qcz!MMxxK7MDto>;tX3ay@VS-!baDY-Jb0YSM-#u1OQ zd{LTw1Qe_q*}f#xd~6)M;4LA?^^k*@&bs;gN50@7Ny$3%LAX!vxBMB|>*0e3*H%Ve zt+j4%x`Yzu>xS>TY}0NABR#@qbaUV4{yb4nlYM^+U!EzCXBc8z_2=5}IO3(C7Ms*o zotyyCont-VQz0u~Y;z4KKbpCA!&SpBI}jI@6}JU>EFQ;=hc>r`{cE%PJD~}A#M?iF zkb-61`_6Je7eMOcK1C0TSu{O1V-d%kK_x5$U2KvpH>{dHIf9BhZT5GRYim+y^(!O0 z$OSfJodiL5L#u8}fbV|GbQm!|uhub2hqaB4y7z5vgPhp%E^?%8pAka)i*9Xvrf_*P9x^t#8p<7Qe-?M$Rfp^?p6#jw{MJo(|gH#&61| zbE_F-1iCfRJ8t!;w^{6K^4j6jxnolfVW#w1XRzpHzW)2FDw~UYG$N9KskF>PrFcUGUaw zdqLX)ig55s;|9$p1$gKy--MTP%>AtY^rN+yISknXix2FSAeLqX{+<-(h83k~p6T@* z36>*Ql-vH~c2Is{4nIJHdD~Mc*%>elEnMfGiQylz6$!dqa-7nHn0O)SD^nE>{joH@ zLx*UUFB&p{o_aW+FbazlODoe$0n_xau?wq53huvK$@+X^BgePPo*k4W`#7B5*t>gL zoz++a(r~voJ(w;ZeG?6j6uKG~M)2h57|eYu2r?d7&6Rh4s4NY6toVcB7mlnPrkO+FBrAQv2e&!c7;h?(N$O>L$#dS* zXA_`OqA(|8L{;bXmr4!viTJ_Nt3D%QZEj|g<$Qb4`B?(iW$)`0_%=`OJ-Yq+zD3Sw zBZ+oysoPV1+C?KleYjN(6VZOz#|dREnlx#u_oEE&;3TJgfR_f37lYKC@@Q=(n>#0k z+Vi_pm+Nq!S+mXuFAdQ*mBPF(ZvKr56~G4r=>Ey&02XRRS+P$uy#j(RW-DtU0Jka# zIC(ry4Cv3->YhN99m-*HLm`g%YFl}!q+wd5K8=RO{IoP3|A#2^GH`H zRxcwVQ~h0If=lO?-+Wi$>NIrGY5d6t6f%Knrcq^9=CmTWbhLq2@nfpQHU-CAjaUMNC)%!44+hzN8_!!3J@3@I^@8b||cu8A2wq;oxM|l7(c*G!P>R zd&B}m$nq>^AGn=4F_(4vplELQd#LZO&ByDDakui*)H7V%xGDow&rOu*h`nbLH*^&m z?B;^cRo-jdlYTQj-RNrNuT?O(qSIHJ)KK$+k-5LT-Ubq_R9JywOH z^B9(kye^+zPMQ&kb`0er2$4AnBmk5SAp1*0K$gBdqJC$;=U0Wm!oO<>Lo7PSubwnN zqnmIrK}J&;u*9072Hm~Tozzj3Y4Sh*!jW}A35qE=@QI8g%gFMfan`!>`PTxlJ1$wi zn2&m^H2bRG+^-7=y0x7QuidZR!$eI;XxEs;-M@@!d+}@L_b2Vo+DC|>VMn}btGuJL&UkpF)Y%Gi;N>mjG!PR_{Dn&?NeIo&`U^ZqN3_h#0fYGx=5}S5f@ihTnFcmc2WUH*UK;W|4R&;3A*2l_!eI`1OF1-On&B+9Z&T z`Kp*gjMko8K$2+)%j%fVl~1hK=oZmQCdPQF%P3ELXCb-fzE_XR}hkTIPFtZ|+A*xQDVp1=jsZt)Tt3#o`Q~%g(nQ z3XjDy+q!9d8Db!Q%rnrokpu9g73+fnwuV;&fl>%@dkHWHUpd63><^aj2Y}sYc4P98=(9e zaAwh}f8T%p;dFmX3P7fiygbd$elK~MOUC{m4gE=bkID%lt8~GWa3nK8u@kl z7p|H6VJ#m;e3`NLu7^lGGg=cRE=VHdLQFHPrVQqVs>;Sk5pv=1K88KJ!$e*_KR;G4 z+(nw|Q~n&mZ-i$POR2Kgy;Pt7K1e)}o8cd0J3$ksg}cH^d?NT8wVzg)3#}DB&S2QJ zjgWXgU9Vei(44DAwxguA&RK7#F?8eK#RK*A^M8+j*{(mUnr1d1Guz^Ct+f z+W685f_b>9AbV8HUpNAWad5^{^O>LM`|NII)*J-qZ)fJ_=*~Cp^Oh`429s2Jpd#~js;?^K6^a!sQ|#oAEr?@#{dOn0 zFG|zTz-+)n>0ZUIP~N=7o%Z=DObsTs04UUN+}>>Z$&Yl?;u4(vfx?00V0{qAk~T!5 zvr@}8Rq`Va!fz2DH!}A#EYo7 zGFM;+0P?1sO9V0+E*sr+%J|JHplg02n&wTF+wUz8noHh&#joG5vd_dIauxBiQs4^} zSWFx56X{6NfQf#QLZdT3RaksS`GFb{pWM+8ipz?I6s>r(&R24a3swXE6ld=Sq_WyE z&270RwimMWx*@4D2W*FkD87u8C8=zmEz}VxyNCXPl<{U;KoQ?&c0o*9Ph(N4Ommv6 z;cViWRWd8~%I~>~>guD}1me3aw@(}Y{5_D!)Y-!Hc!77v>H`V(tNe8YW?h0gu^1st zSGeJ!%blO6wfl( zKS4bEva@@Wy3W6Hq_>k+EA!2>=3+hBYJnl)O#>@Ol#dvkyx-BjXFIOEvhnq$)?d@KVCc&BnT7xZ2r|SCFuF)En~fVZ^<8;I4&MG@ zetYA*_ubkcNd2mGI-?K+5-HAFqWU8?pPM#_Z&IN?-o<&cdbsrSh6o$-!CUb*#!z0g zxU*?ftXk<3a{G%y>!a$T`vSz_A@@IhX#Pn$zM#G_x9WithDGtNtEc-nwnqkD1?-q1 zFg4o#c&hlrs#g6wqq=vw#`X~b?- zBw??s--FEN95)>br7cJy;^O#CVTRXc0%CMWyBziCvDQf*ci`0*Vsja;s4^3U`ei>x7r2nc3tvgBaCz-QQT2LW_b(Ro8W zbYq!t7>)@-g0&M@f`NLm%nBQ#TgG08#2-NK0F-2^=tducjUbd{j46ko#vWIYc_E3W zsr*X3x+>iUo~++&%5*x&@(NTX<3CYj=4SV1kvcoIa`BG7=Fa7g@;cWx3^-Ka`{e0; zMPi=!cvmzi@s1|HZ%F{ZwNBs5r!}dwDn2gV;&Iu2WvdxiE`eY2%4P8F)S~x*z zKtU8ilP}l0_^VMcpw{_Af1NE}$z262%t2DVw_gAA8Z?UCvpNeXNrJ_9*9T zsVEBv0au64iq=vx*!n;n`|R`Cgh#Ac?ijFC2tDB$A&j2Z0GaM*HaL*O8FBRor9`Yd zUf!i9pc~L2yu3>889k+&CI3A4wqES#Yz}lOC!6-46B)FM0@^oaWh~O1VG8=GoOct^ z_i_BD&;ASOJMrQqVR6h}xxYuzll+Br#thNgwFo;bWM;2bniOS3ysoI7W7=6 zSBvL%o-KGt*@#p7pgZ8}C|g_t(Y69hwBohpzdl4YL%v1S0vE4%thr#OicV^SsL68|J1s2NeNB);5KXl$0Dib|fwxlEgt!TyFoNj#ZC7yUI# z^2s_I_#!n!4-{XkoP3>(1C^IWHuhf7pT=Bm@nbTAI)tDt}RGgqYvP`<< zfdo&{OgYsM&2WE`>dZO`31w^uIF~c35X{&Y==8%v2U^0HZbyf~Nnh1QeNIDV@Y_Rj z=_l}>=5^5?^I2Czhxv38tNDug$+d>HQJ>zT(&wqt98~#|{@kkY0^Qo|s00Cw(ZI#l z5Jt_aCWneKYYz{rG1`iBQzbK9`b-TbyN{OLB#oJ zFOQqj(5dq}xs|o{VUT!II?$2T8-Ae2W2DX4BTA(-S}M($dfT>&@pH6f24g%a_N$>7 zT=g<7$&&~7G|EjyoR^M8!J^8cf)dBjnQd!O=fDHy#(kadW&_9PxoZ%@$kGtSBT6Mm zBvu0y$4o|(7jZelhuY9l>K&DvN`gP5<>t7&9IjwDa^bSy%5(3>hh{as{m==O{y$o| M|D)yo|1#*`03iv8i~s-t literal 0 HcmV?d00001 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2652f9df1..ee63d673a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -42,6 +42,7 @@ "expo-localization": "~17.0.8", "expo-router": "~6.0.17", "expo-secure-store": "^15.0.8", + "expo-speech-recognition": "^3.1.2", "expo-splash-screen": "~31.0.12", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index c3597430d..df35d9eb4 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -15,6 +15,8 @@ export default function SettingsScreen() { const { data: userData } = useUserQuery(); const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); const setAiChatEnabled = usePreferencesStore((s) => s.setAiChatEnabled); + const pingsEnabled = usePreferencesStore((s) => s.pingsEnabled); + const setPingsEnabled = usePreferencesStore((s) => s.setPingsEnabled); const handleLogout = async () => { await logout(); @@ -109,6 +111,17 @@ export default function SettingsScreen() { + + + + Enable pings + + + Play a sound when a task completes + + + + {/* All Settings Button */} diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 047ef650e..9d47cb68c 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -55,11 +55,14 @@ export default function TaskDetailScreen() { setTask(fetchedTask); return connectToTask(fetchedTask); }) + .then(() => { + // Brief delay for FlatList to render its initial batch behind + // the loading overlay before revealing. + setTimeout(() => setLoading(false), 150); + }) .catch((err) => { console.error("Failed to load task:", err); setError("Failed to load task"); - }) - .finally(() => { setLoading(false); }); @@ -85,28 +88,7 @@ export default function TaskDetailScreen() { [router], ); - if (loading) { - return ( - <> - - - - Loading task... - - - ); - } - - if (error || !task) { + if (error || (!task && !loading)) { return ( <> @@ -142,7 +124,7 @@ export default function TaskDetailScreen() { options={{ headerShown: true, headerTransparent: false, - headerTitle: task.title || "Task", + headerTitle: loading ? "Loading..." : task?.title || "Task", headerStyle: { backgroundColor: themeColors.background }, headerTintColor: themeColors.gray[12], headerTitleStyle: { @@ -171,48 +153,36 @@ export default function TaskDetailScreen() { }} /> + {/* Always render TaskSessionView so the FlatList can layout behind + the loading overlay. This prevents the "flash of messages" when + switching from loading spinner to rendered content. */} + {/* Loading overlay — covers the list while it does initial layout */} + {loading && ( + + + Loading task... + + )} + {/* Fixed input at bottom */} - {session?.terminalStatus && ( - - - {session.terminalStatus === "failed" - ? `Run failed${session.lastError ? `: ${session.lastError}` : ""}` - : "Run completed"} - - {session.terminalStatus === "failed" && ( - - Send a message to start a new run. - - )} - - )} - + diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx index 96f3f1bc8..8a5e86042 100644 --- a/apps/mobile/src/features/chat/components/AgentMessage.tsx +++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx @@ -5,6 +5,7 @@ import { useThemeColors } from "@/lib/theme"; import { usePeriodicRerender } from "../hooks/usePeriodicRerender"; import type { AssistantToolCall } from "../types"; import { getRandomThinkingMessage } from "../utils/thinkingMessages"; +import { MarkdownText } from "./MarkdownText"; import { ToolMessage } from "./ToolMessage"; interface AgentMessageProps { @@ -107,9 +108,7 @@ export function AgentMessage({ {/* Show final content */} {content && ( - - {content} - + )} diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index d7f5bb9ef..f6f9f803a 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -1,8 +1,10 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; import { ArrowUp, Microphone, Stop } from "phosphor-react-native"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, + Animated, + Easing, Platform, TextInput, TouchableOpacity, @@ -15,12 +17,74 @@ interface ComposerProps { onSend: (message: string) => void; disabled?: boolean; placeholder?: string; + isUserTurn?: boolean; +} + +function PulsingBorder({ + active, + color, +}: { + active: boolean; + color: string; +}) { + const opacity = useRef(new Animated.Value(0)).current; + const animRef = useRef(null); + + useEffect(() => { + if (active) { + opacity.setValue(0); + animRef.current = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]), + ); + animRef.current.start(); + } else { + animRef.current?.stop(); + animRef.current = null; + opacity.setValue(0); + } + return () => { + animRef.current?.stop(); + }; + }, [active, opacity]); + + if (!active) return null; + + return ( + + ); } export function Composer({ onSend, disabled = false, placeholder = "Ask a question", + isUserTurn = false, }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); @@ -85,40 +149,46 @@ export function Composer({ gap: 8, }} > - {/* Input field with rounded glass background */} - - + + - + isInteractive + > + + + {/* Mic / Send button */} 0) { + blocks.push({ type: "paragraph", content: paraLines.join("\n") }); + } + } + + return blocks; +} + +function renderInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; + let lastIndex = 0; + let match: RegExpExecArray | null = null; + + // biome-ignore lint/suspicious/noAssignInExpressions: regex exec loop + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + if (match[2]) { + nodes.push( + + {match[2]} + , + ); + } else if (match[3]) { + nodes.push( + + {match[3]} + , + ); + } else if (match[4]) { + nodes.push( + + {match[4]} + , + ); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +export function MarkdownText({ content }: MarkdownTextProps) { + const blocks = parseBlocks(content); + + return ( + + {blocks.map((block, i) => { + const key = `block-${i}`; + + switch (block.type) { + case "code": + return ( + + + {block.content} + + + ); + + case "heading": + return ( + + {renderInline(block.content)} + + ); + + case "list": + return ( + + {block.items?.map((item, idx) => ( + + + {block.ordered ? `${idx + 1}.` : "•"} + + + {renderInline(item)} + + + ))} + + ); + + default: + return ( + + {renderInline(block.content)} + + ); + } + })} + + ); +} diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index ee6085fcd..a5ab7f8a8 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -54,6 +54,81 @@ const kindIcons: Record = { other: Wrench, }; +export function deriveToolKind(toolName: string): ToolKind { + // Agent titles can include file paths, e.g. "Edit `src/foo.ts`" or + // "Read 200 lines in `bar.ts`", so match on prefix / keyword. + const name = toolName.toLowerCase(); + if (name.startsWith("read") || name === "read_file") return "read"; + if ( + name.startsWith("edit") || + name.startsWith("write") || + name.startsWith("multiedit") || + name.startsWith("multi_edit") || + name === "search_replace" + ) + return "edit"; + if (name.startsWith("delete")) return "delete"; + if ( + name.startsWith("grep") || + name.startsWith("search") || + name.startsWith("glob") || + name.startsWith("find") || + name.startsWith("list") + ) + return "search"; + if ( + name.startsWith("bash") || + name.startsWith("execute") || + name.startsWith("terminal") + ) + return "execute"; + if (name.startsWith("think")) return "think"; + if (name.startsWith("webfetch") || name.startsWith("fetch")) return "fetch"; + if (name === "create_task") return "create_task"; + return "other"; +} + +export function getToolSubtitle( + toolName: string, + args?: Record, +): string | null { + if (!args) return null; + const kind = deriveToolKind(toolName); + + switch (kind) { + case "read": + case "edit": + case "delete": + case "move": + if (typeof args.file_path === "string") + return shortenPath(args.file_path); + if (typeof args.target_file === "string") + return shortenPath(args.target_file); + return null; + case "search": + if (typeof args.pattern === "string") return `"${args.pattern}"`; + return null; + case "execute": + if (typeof args.command === "string") + return args.command.length > 60 + ? `${args.command.slice(0, 60)}...` + : args.command; + return null; + case "fetch": + if (typeof args.url === "string") + return args.url.length > 60 ? `${args.url.slice(0, 60)}...` : args.url; + return null; + case "think": + if (typeof args.content === "string") + return args.content.length > 60 + ? `${args.content.slice(0, 60)}...` + : args.content; + return null; + default: + return null; + } +} + interface CreateTaskArgs { title?: string; description?: string; @@ -93,6 +168,403 @@ export function formatToolTitle( return toolName; } +// Shape guards for file-editing tool args. The agent forwards Claude's raw +// tool input through the ACP `rawInput` field, so we can detect Edit / Write / +// MultiEdit by the keys present in args. +interface EditArgs { + file_path: string; + old_string: string; + new_string: string; +} + +interface MultiEditArgs { + file_path: string; + edits: Array<{ old_string: string; new_string: string }>; +} + +interface WriteArgs { + file_path: string; + content: string; +} + +function asEditArgs( + args: Record | undefined, +): EditArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.old_string === "string" && + typeof args.new_string === "string" + ) { + return { + file_path: args.file_path, + old_string: args.old_string, + new_string: args.new_string, + }; + } + return null; +} + +function asMultiEditArgs( + args: Record | undefined, +): MultiEditArgs | null { + if (!args || typeof args.file_path !== "string") return null; + if (!Array.isArray(args.edits)) return null; + const edits: MultiEditArgs["edits"] = []; + for (const raw of args.edits) { + if ( + raw && + typeof raw === "object" && + typeof (raw as Record).old_string === "string" && + typeof (raw as Record).new_string === "string" + ) { + edits.push({ + old_string: (raw as Record).old_string as string, + new_string: (raw as Record).new_string as string, + }); + } + } + if (edits.length === 0) return null; + return { file_path: args.file_path, edits }; +} + +function asWriteArgs( + args: Record | undefined, +): WriteArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.content === "string" && + args.old_string === undefined + ) { + return { file_path: args.file_path, content: args.content }; + } + return null; +} + +// Strip ANSI escape codes from terminal output +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI codes requires matching control chars + return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); +} + +function extractResultText(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + if (typeof obj[key] === "string") return obj[key] as string; + } + } + return null; +} + +function countDiffLines( + editArgs: EditArgs | null, + multiEditArgs: MultiEditArgs | null, + writeArgs: WriteArgs | null, +): { added: number; removed: number } { + let added = 0; + let removed = 0; + + const countFromDiff = (oldText: string, newText: string) => { + const lines = computeLineDiff(oldText, newText, Number.MAX_SAFE_INTEGER); + for (const line of lines) { + if (line.kind === "added") added++; + else if (line.kind === "removed") removed++; + } + }; + + if (editArgs) { + countFromDiff(editArgs.old_string ?? "", editArgs.new_string ?? ""); + } else if (multiEditArgs) { + for (const edit of multiEditArgs.edits) { + countFromDiff(edit.old_string ?? "", edit.new_string ?? ""); + } + } else if (writeArgs) { + added = writeArgs.content ? writeArgs.content.split("\n").length : 0; + } + + return { added, removed }; +} + +// Extract a file path from agent tool titles like "Read `src/foo.ts`" or +// "Read 200 lines in `bar.ts`" when rawInput/args are unavailable. +function extractPathFromTitle(title: string): string | null { + const backtickMatch = title.match(/`([^`]+)`/); + if (backtickMatch) return backtickMatch[1]; + // Fallback: strip common prefixes like "Read file", "Read 200 lines in" + const stripped = title + .replace(/^read\s+/i, "") + .replace(/^file\s*/i, "") + .replace(/^\d+\s+lines?\s+in\s+/i, "") + .trim(); + // Only treat the remainder as a path if it looks like one + if (stripped.includes("/") || stripped.includes(".")) return stripped; + return null; +} + +function shortenPath(path: string, maxLen = 48): string { + if (path.length <= maxLen) return path; + const parts = path.split("/"); + if (parts.length <= 2) return `…${path.slice(-(maxLen - 1))}`; + return `…/${parts.slice(-2).join("/")}`; +} + +// Unified diff support — detects and renders `git diff` output when the agent +// runs commands like `git diff` through the Bash tool and the result comes +// back as stdout rather than a structured tool content block. +type UnifiedDiffLine = + | { kind: "file"; text: string } + | { kind: "hunk"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "context"; text: string } + | { kind: "meta"; text: string }; + +function looksLikeUnifiedDiff(text: string): boolean { + if (!text) return false; + if (/(^|\n)diff --git /.test(text)) return true; + return /(^|\n)--- /.test(text) && /(^|\n)\+\+\+ /.test(text); +} + +function extractDiffFromResult(result: unknown): string | null { + if (typeof result === "string") { + return looksLikeUnifiedDiff(result) ? result : null; + } + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + const value = obj[key]; + if (typeof value === "string" && looksLikeUnifiedDiff(value)) { + return value; + } + } + } + return null; +} + +function parseUnifiedDiff(text: string): UnifiedDiffLine[] { + const result: UnifiedDiffLine[] = []; + for (const line of text.split("\n")) { + if ( + line.startsWith("diff --git ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("index ") || + line.startsWith("new file mode") || + line.startsWith("deleted file mode") || + line.startsWith("similarity index") || + line.startsWith("rename ") + ) { + result.push({ kind: "file", text: line }); + } else if (line.startsWith("@@")) { + result.push({ kind: "hunk", text: line }); + } else if (line.startsWith("+")) { + result.push({ kind: "added", text: line }); + } else if (line.startsWith("-")) { + result.push({ kind: "removed", text: line }); + } else if (line.startsWith(" ")) { + result.push({ kind: "context", text: line }); + } else { + result.push({ kind: "meta", text: line }); + } + } + return result; +} + +interface UnifiedDiffBlockProps { + diffText: string; + maxLines?: number; +} + +function UnifiedDiffBlock({ diffText, maxLines = 120 }: UnifiedDiffBlockProps) { + const allLines = parseUnifiedDiff(diffText); + const truncated = allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + let cls = "font-mono text-[11px] leading-4 text-gray-11 px-2"; + if (line.kind === "file") { + cls += " text-gray-9"; + } else if (line.kind === "hunk") { + cls += " bg-accent-3 text-accent-11"; + } else if (line.kind === "added") { + cls += " bg-status-success/10 text-status-success"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10 text-status-error"; + } else if (line.kind === "context") { + cls += " text-gray-11"; + } else { + cls += " text-gray-9"; + } + return ( + + {line.text || " "} + + ); + })} + {truncated && ( + + … {allLines.length - maxLines} more lines + + )} + + ); +} + +// LCS-based line diff: correctly identifies unchanged lines even when +// changes are scattered throughout the block, then collapses distant +// context into separators. +type DiffLine = + | { kind: "context"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "separator" }; + +// O(n*m) LCS — fine for typical edit blocks (< 200 lines). +function lcsBacktrack(a: string[], b: string[]): DiffLine[] { + const m = a.length; + const n = b.length; + + // Build LCS table + const dp: number[][] = []; + for (let i = 0; i <= m; i++) { + dp[i] = new Array(n + 1).fill(0); + } + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + // Backtrack to produce diff + const result: DiffLine[] = []; + let i = m; + let j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + result.push({ kind: "context", text: a[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.push({ kind: "added", text: b[j - 1] }); + j--; + } else { + result.push({ kind: "removed", text: a[i - 1] }); + i--; + } + } + result.reverse(); + return result; +} + +// Collapse context lines far from changes into separators. +function collapseContext(lines: DiffLine[], contextLines: number): DiffLine[] { + // Mark which lines are near a change + const isChange = lines.map((l) => l.kind === "added" || l.kind === "removed"); + const nearChange = new Array(lines.length).fill(false); + for (let i = 0; i < lines.length; i++) { + if (isChange[i]) { + for ( + let k = Math.max(0, i - contextLines); + k <= Math.min(lines.length - 1, i + contextLines); + k++ + ) { + nearChange[k] = true; + } + } + } + + const result: DiffLine[] = []; + let inSkip = false; + for (let i = 0; i < lines.length; i++) { + if (nearChange[i] || isChange[i]) { + inSkip = false; + result.push(lines[i]); + } else if (!inSkip) { + inSkip = true; + result.push({ kind: "separator" }); + } + } + return result; +} + +function computeLineDiff( + oldText: string, + newText: string, + contextLines = 2, +): DiffLine[] { + const oldLines = oldText.length > 0 ? oldText.split("\n") : []; + const newLines = newText.length > 0 ? newText.split("\n") : []; + + if (oldLines.length === 0) { + return newLines.map((l) => ({ kind: "added" as const, text: l })); + } + if (newLines.length === 0) { + return oldLines.map((l) => ({ kind: "removed" as const, text: l })); + } + + const raw = lcsBacktrack(oldLines, newLines); + return collapseContext(raw, contextLines); +} + +interface DiffBlockProps { + oldText: string; + newText: string; + maxLines?: number; +} + +function DiffBlock({ oldText, newText, maxLines = 60 }: DiffBlockProps) { + const allLines = computeLineDiff(oldText, newText); + const truncated = allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + const key = `${line.kind}-${i}`; + if (line.kind === "separator") { + return ( + + ··· + + ); + } + let cls = "font-mono text-[11px] leading-4 px-2"; + if (line.kind === "added") { + cls += " bg-status-success/10 text-status-success"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10 text-status-error"; + } else { + cls += " text-gray-11"; + } + const prefix = + line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "; + return ( + + {prefix} + {line.text || " "} + + ); + })} + {truncated && ( + + … {allLines.length - maxLines} more lines + + )} + + ); +} + function CreateTaskPreview({ args, showAction, @@ -234,13 +706,117 @@ export function ToolMessage({ const isLoading = status === "pending" || status === "running"; const isFailed = status === "error"; - const hasDetails = args || result !== undefined; const displayTitle = formatToolTitle(toolName, args); const KindIcon = kind ? kindIcons[kind] : Wrench; const isCreateTask = toolName.toLowerCase() === "create_task" || kind === "create_task"; + // File-editing tools get a proper diff view using the rawInput we already + // receive on the wire. Detection is by shape, not tool name, so it works + // regardless of how the agent labels the tool. + const editArgs = asEditArgs(args); + const multiEditArgs = !editArgs ? asMultiEditArgs(args) : null; + const writeArgs = !editArgs && !multiEditArgs ? asWriteArgs(args) : null; + const fileToolArgs = editArgs ?? multiEditArgs ?? writeArgs; + + // Unified-diff-in-result: when the agent runs commands like `git diff` + // via the Bash tool, the result comes back as stdout containing a unified + // diff string. Detect that and render it as a real diff view. + const unifiedDiffText = !fileToolArgs ? extractDiffFromResult(result) : null; + + if (fileToolArgs && !isCreateTask) { + const stats = countDiffLines(editArgs, multiEditArgs, writeArgs); + // Collapse diffs for failed edits (retries make them noise) + const showDiff = !isFailed || isOpen; + + return ( + + {/* Header row */} + isFailed && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!isFailed} + > + {isLoading ? ( + + ) : ( + + )} + + {shortenPath(fileToolArgs.file_path)} + + {stats.added > 0 && !isFailed && ( + + +{stats.added} + + )} + {stats.removed > 0 && !isFailed && ( + + -{stats.removed} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Diff content — collapsed when failed */} + {showDiff && ( + <> + {editArgs && ( + + )} + {multiEditArgs?.edits.map((edit, i) => ( + + ))} + {writeArgs && } + + )} + + ); + } + + // Unified-diff-in-result renderer (e.g. `git diff` via Bash) + if (unifiedDiffText && !isCreateTask) { + return ( + + + {isLoading ? ( + + ) : ( + + )} + + {displayTitle} + + {isFailed && ( + (Failed) + )} + + + + ); + } + // For create_task, show rich preview instead of expandable if (isCreateTask && args) { return ( @@ -264,18 +840,169 @@ export function ToolMessage({ ); } + const resolvedKind = kind ?? deriveToolKind(toolName); + const isPending = status === "pending"; + const isRunning = status === "running"; + const isCompleted = status === "completed"; + const resultText = extractResultText(result); + + // Execute/Bash: show description + command subtitle + expandable output + if (resolvedKind === "execute") { + const command = typeof args?.command === "string" ? args.command : null; + const description = + typeof args?.description === "string" ? args.description : null; + const outputText = resultText ? stripAnsi(resultText) : null; + const hasOutput = outputText && outputText.trim().length > 0; + + return ( + + {/* Header */} + hasOutput && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasOutput} + > + {isLoading ? ( + + ) : ( + + )} + + {description ?? displayTitle} + + {isFailed && ( + + Failed + + )} + + + {/* Command as subtitle line */} + {command && ( + + $ {command} + + )} + + {/* Output */} + {isOpen && hasOutput && ( + + + {outputText} + + + )} + + ); + } + + // Read: show file path, line range, and expandable content preview + if (resolvedKind === "read") { + // Try args first, then extract a path from the tool title (e.g. + // "Read `src/foo.ts`" or "Read 200 lines in `bar.ts`"). + const filePath = + typeof args?.file_path === "string" + ? args.file_path + : typeof args?.target_file === "string" + ? args.target_file + : extractPathFromTitle(toolName); + const hasContent = resultText && resultText.trim().length > 0; + const lineCount = hasContent ? resultText.split("\n").length : null; + const offset = typeof args?.offset === "number" ? args.offset : null; + const limit = typeof args?.limit === "number" ? args.limit : null; + const lineRange = offset + ? `lines ${offset}–${offset + (limit ?? lineCount ?? 0)}` + : lineCount + ? `${lineCount} lines` + : null; + + return ( + + hasContent && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasContent} + > + {isLoading ? ( + + ) : ( + + )} + + Read + + {filePath ? ( + + {shortenPath(filePath, 36)} + + ) : null} + {lineRange && isCompleted && ( + + {lineRange} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Content preview */} + {isOpen && hasContent && ( + + + {resultText} + + + )} + + ); + } + + // Default: all other tools (search, think, fetch, etc.) + const subtitle = getToolSubtitle(toolName, args); + return ( - - hasDetails && setIsOpen(!isOpen)} - className="flex-row items-center gap-2" - disabled={!hasDetails} - > + + {/* Status indicator */} {isLoading ? ( ) : ( - + )} {/* Tool name */} @@ -283,45 +1010,28 @@ export function ToolMessage({ {displayTitle} + {/* Queued label */} + {isPending && ( + Queued + )} + {/* Failed indicator */} {isFailed && ( - (Failed) + + Failed + )} - - - {/* Expanded content */} - {isOpen && hasDetails && ( - - {args && ( - - - Arguments - - - - {JSON.stringify(args, null, 2)} - - - - )} - {result !== undefined && ( - - - Result - - - - {typeof result === "string" - ? result - : JSON.stringify(result, null, 2)} - - - - )} - + + + {/* Contextual subtitle */} + {subtitle && !isPending && ( + + {subtitle} + )} ); diff --git a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts index 5683fbe39..e1302b142 100644 --- a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts +++ b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts @@ -1,7 +1,5 @@ -import { Audio } from "expo-av"; -import { File } from "expo-file-system"; +import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; import { useCallback, useRef, useState } from "react"; -import { useAuthStore } from "@/features/auth"; type RecordingStatus = "idle" | "recording" | "transcribing" | "error"; @@ -16,148 +14,128 @@ interface UseVoiceRecordingReturn { export function useVoiceRecording(): UseVoiceRecordingReturn { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); - const recordingRef = useRef(null); + const transcriptRef = useRef(""); + const resolveRef = useRef<((text: string | null) => void) | null>(null); + const listenersRef = useRef<(() => void)[]>([]); + + const cleanup = useCallback(() => { + for (const remove of listenersRef.current) { + remove(); + } + listenersRef.current = []; + resolveRef.current = null; + transcriptRef.current = ""; + }, []); const startRecording = useCallback(async () => { try { setError(null); + transcriptRef.current = ""; - // Request permissions - const { granted } = await Audio.requestPermissionsAsync(); - if (!granted) { - setError("Microphone permission is required"); + if (!ExpoSpeechRecognitionModule.isRecognitionAvailable()) { + setError("Speech recognition is not available on this device"); setStatus("error"); return; } - // Configure audio mode for recording - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - - // Create and start recording - const recording = new Audio.Recording(); - await recording.prepareToRecordAsync( - Audio.RecordingOptionsPresets.HIGH_QUALITY, - ); - await recording.startAsync(); - recordingRef.current = recording; - setStatus("recording"); - } catch (err) { - console.error("Failed to start recording:", err); - setError("Failed to start recording"); - setStatus("error"); - } - }, []); - - const stopRecording = useCallback(async (): Promise => { - if (!recordingRef.current) { - return null; - } - - try { - setStatus("transcribing"); - - // Stop recording and get URI - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - recordingRef.current = null; - - // Reset audio mode - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - }); - - if (!uri) { - setError("No recording found"); + const { granted } = + await ExpoSpeechRecognitionModule.requestPermissionsAsync(); + if (!granted) { + setError("Speech recognition permission is required"); setStatus("error"); - return null; + return; } - const { - oauthAccessToken, - cloudRegion, - projectId, - getCloudUrlFromRegion, - } = useAuthStore.getState(); - - if (!oauthAccessToken || !cloudRegion || !projectId) { - setError("Not authenticated"); - setStatus("error"); - return null; - } + // Listen for results — accumulate the latest transcript + const resultSub = ExpoSpeechRecognitionModule.addListener( + "result", + (event) => { + const best = event.results[0]?.transcript; + if (best) { + transcriptRef.current = best; + } + if (event.isFinal && resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }, + ); - const cloudUrl = getCloudUrlFromRegion(cloudRegion); - - // Create form data with the recording file - const formData = new FormData(); - formData.append("file", { - uri, - type: "audio/mp4", - name: "recording.m4a", - } as unknown as Blob); - - // Call PostHog LLM Gateway transcription API - const response = await fetch( - `${cloudUrl}/api/projects/${projectId}/llm_gateway/v1/audio/transcriptions`, - { - method: "POST", - headers: { - Authorization: `Bearer ${oauthAccessToken}`, - }, - body: formData, + const errorSub = ExpoSpeechRecognitionModule.addListener( + "error", + (event) => { + // "no-speech" is not a real error — just means the user didn't say anything + if (event.error === "no-speech") { + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("idle"); + return; + } + setError(event.message || "Speech recognition failed"); + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("error"); }, ); - // Clean up the temp file - const recordingFile = new File(uri); - if (recordingFile.exists) { - await recordingFile.delete(); - } + // If recognition ends without a final result (e.g. silence timeout) + const endSub = ExpoSpeechRecognitionModule.addListener("end", () => { + if (resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }); - if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Transcription failed: ${errorData}`); - } + listenersRef.current = [ + () => resultSub.remove(), + () => errorSub.remove(), + () => endSub.remove(), + ]; + + const useOnDevice = + ExpoSpeechRecognitionModule.supportsOnDeviceRecognition(); + + ExpoSpeechRecognitionModule.start({ + lang: "en-US", + interimResults: true, + requiresOnDeviceRecognition: useOnDevice, + addsPunctuation: true, + }); - const data = await response.json(); - setStatus("idle"); - return data.text; + setStatus("recording"); } catch (err) { - console.error("Failed to transcribe:", err); - const errorMessage = - err instanceof Error ? err.message : "Transcription failed"; - setError(errorMessage); + console.error("Failed to start speech recognition:", err); + setError("Failed to start speech recognition"); setStatus("error"); - return null; } - }, []); + }, [cleanup]); - const cancelRecording = useCallback(async () => { - if (recordingRef.current) { - try { - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - if (uri) { - const file = new File(uri); - if (file.exists) { - await file.delete(); - } - } - } catch { - // Ignore cleanup errors - } - recordingRef.current = null; + const stopRecording = useCallback(async (): Promise => { + if (status !== "recording") { + return null; } - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, + setStatus("transcribing"); + + return new Promise((resolve) => { + resolveRef.current = resolve; + // stop() asks the recognizer to deliver a final result then end + ExpoSpeechRecognitionModule.stop(); }); + }, [status]); + const cancelRecording = useCallback(async () => { + ExpoSpeechRecognitionModule.abort(); + cleanup(); setStatus("idle"); setError(null); - }, []); + }, [cleanup]); return { status, diff --git a/apps/mobile/src/features/chat/index.ts b/apps/mobile/src/features/chat/index.ts index 3ca5c7509..cc4d7c8d1 100644 --- a/apps/mobile/src/features/chat/index.ts +++ b/apps/mobile/src/features/chat/index.ts @@ -11,7 +11,7 @@ export type { ToolMessageProps, ToolStatus, } from "./components/ToolMessage"; -export { ToolMessage } from "./components/ToolMessage"; +export { deriveToolKind, ToolMessage } from "./components/ToolMessage"; export { VisualizationArtifact } from "./components/VisualizationArtifact"; // Hooks diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts index 557c110a9..1e44c1cce 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -5,6 +5,8 @@ import { createJSONStorage, persist } from "zustand/middleware"; interface PreferencesState { aiChatEnabled: boolean; setAiChatEnabled: (enabled: boolean) => void; + pingsEnabled: boolean; + setPingsEnabled: (enabled: boolean) => void; } export const usePreferencesStore = create()( @@ -12,11 +14,16 @@ export const usePreferencesStore = create()( (set) => ({ aiChatEnabled: false, setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), + pingsEnabled: true, + setPingsEnabled: (enabled) => set({ pingsEnabled: enabled }), }), { name: "posthog-preferences", storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ aiChatEnabled: state.aiChatEnabled }), + partialize: (state) => ({ + aiChatEnabled: state.aiChatEnabled, + pingsEnabled: state.pingsEnabled, + }), }, ), ); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 66d2999b5..336daadb9 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -289,7 +289,11 @@ export class CloudCommandError extends Error { /** True when the cloud sandbox for this run has terminated. */ isSandboxInactive(): boolean { - return !!this.backendError?.includes("No active sandbox"); + return ( + !!this.backendError?.includes("No active sandbox") || + !!this.backendError?.includes("returned 404") || + this.status === 404 + ); } } diff --git a/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx new file mode 100644 index 000000000..2c8633cc5 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx @@ -0,0 +1,88 @@ +import { CheckCircle, CircleDashed, XCircle } from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { PlanEntry } from "../types"; + +interface PlanStatusBarProps { + plan: PlanEntry[] | null; +} + +function StatusIcon({ status }: { status: string }) { + const themeColors = useThemeColors(); + + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + case "failed": + return ; + default: + return ; + } +} + +export function PlanStatusBar({ plan }: PlanStatusBarProps) { + const [isExpanded, setIsExpanded] = useState(false); + const themeColors = useThemeColors(); + + const stats = useMemo(() => { + if (!plan?.length) return null; + + const completed = plan.filter((e) => e.status === "completed").length; + const total = plan.length; + const inProgress = plan.find((e) => e.status === "in_progress"); + const allCompleted = completed === total; + + return { completed, total, inProgress, allCompleted }; + }, [plan]); + + if (!stats || stats.allCompleted) return null; + + return ( + + setIsExpanded(!isExpanded)} + className="flex-row items-center gap-2 px-4 py-2.5" + > + + {stats.completed}/{stats.total} completed + + {stats.inProgress && ( + <> + · + + + {stats.inProgress.content} + + + )} + + + {isExpanded && plan && ( + + {plan.map((entry) => ( + + + + {entry.content} + + + ))} + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/QuestionCard.tsx b/apps/mobile/src/features/tasks/components/QuestionCard.tsx new file mode 100644 index 000000000..af70b5f4c --- /dev/null +++ b/apps/mobile/src/features/tasks/components/QuestionCard.tsx @@ -0,0 +1,341 @@ +import { + ChatCircle, + CheckCircle, + CircleDashed, + RadioButton, +} from "phosphor-react-native"; +import { useState } from "react"; +import { + Pressable, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import type { ToolStatus } from "@/features/chat"; +import { useThemeColors } from "@/lib/theme"; + +interface QuestionOption { + label: string; + description?: string; +} + +interface QuestionItem { + question: string; + header?: string; + options: QuestionOption[]; + multiSelect?: boolean; +} + +interface ToolData { + toolName: string; + toolCallId: string; + status: ToolStatus; + args?: Record; + result?: unknown; +} + +interface QuestionCardProps { + toolData: ToolData; + onSendAnswer?: (answer: string) => void; +} + +function extractQuestions(args?: Record): QuestionItem[] { + if (!args) return []; + // Questions may be at top level or nested under input + const raw = + args.questions ?? (args.input as Record)?.questions; + if (!Array.isArray(raw)) return []; + return raw.filter( + (q): q is QuestionItem => + q != null && + typeof q === "object" && + typeof (q as QuestionItem).question === "string" && + Array.isArray((q as QuestionItem).options), + ); +} + +function extractAnswer(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + if (typeof obj.answer === "string") return obj.answer; + if (typeof obj.answers === "object" && obj.answers) { + const answers = obj.answers as Record; + return Object.values(answers).join(", "); + } + if (typeof obj.text === "string") return obj.text; + if (typeof obj.content === "string") return obj.content; + } + return null; +} + +export function QuestionCard({ toolData, onSendAnswer }: QuestionCardProps) { + const themeColors = useThemeColors(); + const questions = extractQuestions(toolData.args); + const isCompleted = + toolData.status === "completed" || toolData.status === "error"; + + if (questions.length === 0) { + return null; + } + + if (isCompleted) { + const answer = extractAnswer(toolData.result); + return ( + + + + + {questions[0]?.header ?? "Question"} + + + + + {questions[0]?.question} + + {answer && ( + + + + {answer} + + + )} + + + ); + } + + return ( + + ); +} + +function InteractiveQuestion({ + questions, + onSendAnswer, +}: { + questions: QuestionItem[]; + onSendAnswer?: (answer: string) => void; +}) { + const themeColors = useThemeColors(); + const [currentIndex, setCurrentIndex] = useState(0); + const [selectedOptions, setSelectedOptions] = useState< + Map> + >(new Map()); + const [otherTexts, setOtherTexts] = useState>(new Map()); + const [showOtherInput, setShowOtherInput] = useState>( + new Map(), + ); + + const question = questions[currentIndex]; + if (!question) return null; + + const isMultiSelect = question.multiSelect ?? false; + const isLastQuestion = currentIndex === questions.length - 1; + const selected = selectedOptions.get(currentIndex) ?? new Set(); + const otherText = otherTexts.get(currentIndex) ?? ""; + const isOtherShown = showOtherInput.get(currentIndex) ?? false; + const hasSelection = selected.size > 0 || otherText.trim().length > 0; + + const toggleOption = (label: string) => { + const newSelected = new Map(selectedOptions); + const current = new Set(selected); + + if (isMultiSelect) { + if (current.has(label)) { + current.delete(label); + } else { + current.add(label); + } + } else { + if (current.has(label)) { + current.clear(); + } else { + current.clear(); + current.add(label); + } + // Clear "Other" when selecting a preset option + const newOther = new Map(showOtherInput); + newOther.set(currentIndex, false); + setShowOtherInput(newOther); + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, ""); + setOtherTexts(newTexts); + } + + newSelected.set(currentIndex, current); + setSelectedOptions(newSelected); + }; + + const toggleOther = () => { + const newOther = new Map(showOtherInput); + const isNowShown = !isOtherShown; + newOther.set(currentIndex, isNowShown); + setShowOtherInput(newOther); + + if (!isMultiSelect && isNowShown) { + // Clear preset selections when choosing "Other" in single-select + const newSelected = new Map(selectedOptions); + newSelected.set(currentIndex, new Set()); + setSelectedOptions(newSelected); + } + }; + + const handleSubmit = () => { + const parts: string[] = []; + for (const label of selected) { + parts.push(label); + } + if (otherText.trim()) { + parts.push(otherText.trim()); + } + const answer = parts.join(", "); + + if (isLastQuestion) { + if (answer && onSendAnswer) { + onSendAnswer(answer); + } + } else { + setCurrentIndex(currentIndex + 1); + } + }; + + return ( + + {/* Header */} + + + + {question.header ?? "Question"} + + {questions.length > 1 && ( + + {currentIndex + 1}/{questions.length} + + )} + + + {/* Question text */} + + + {question.question} + + + + {/* Options */} + + {question.options.map((option) => { + const isSelected = selected.has(option.label); + return ( + toggleOption(option.label)} + className={`mb-1.5 rounded-lg border px-3 py-2.5 ${ + isSelected + ? "border-accent-8 bg-accent-3" + : "border-gray-6 bg-gray-3" + }`} + > + + {isMultiSelect ? ( + isSelected ? ( + + ) : ( + + ) + ) : isSelected ? ( + + ) : ( + + )} + + {option.label} + + + {option.description && ( + + {option.description} + + )} + + ); + })} + + {/* Other option */} + + + Other... + + + + {isOtherShown && ( + { + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, text); + setOtherTexts(newTexts); + }} + multiline + autoFocus + /> + )} + + + {/* Submit */} + + + + {isLastQuestion ? "Submit" : "Next"} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 79002dc25..53faa1e28 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -1,18 +1,33 @@ -import { useCallback, useMemo } from "react"; -import { ActivityIndicator, FlatList, Text, View } from "react-native"; +import { + ArrowDown, + Brain, + CaretRight, + Robot, +} from "phosphor-react-native"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + FlatList, + Pressable, + Text, + View, +} from "react-native"; import { AgentMessage, + deriveToolKind, HumanMessage, ToolMessage, type ToolStatus, } from "@/features/chat"; import { useThemeColors } from "@/lib/theme"; -import type { SessionEvent, SessionNotification } from "../types"; +import type { PlanEntry, SessionEvent, SessionNotification } from "../types"; +import { PlanStatusBar } from "./PlanStatusBar"; +import { QuestionCard } from "./QuestionCard"; interface TaskSessionViewProps { events: SessionEvent[]; - isPromptPending: boolean; onOpenTask?: (taskId: string) => void; + onSendAnswer?: (answer: string) => void; contentContainerStyle?: object; } @@ -22,6 +37,8 @@ interface ToolData { status: ToolStatus; args?: Record; result?: unknown; + isAgent?: boolean; + parentToolCallId?: string; } interface ParsedMessage { @@ -29,6 +46,7 @@ interface ParsedMessage { type: "user" | "agent" | "thought" | "tool"; content: string; toolData?: ToolData; + children?: ParsedMessage[]; } function mapToolStatus( @@ -48,11 +66,14 @@ function mapToolStatus( } } -function parseSessionNotification(notification: SessionNotification): { - type: "user" | "agent" | "thought" | "tool" | "tool_update"; - content?: string; - toolData?: ToolData; -} | null { +type ParsedNotification = + | { type: "user" | "agent" | "agent_complete" | "thought"; content: string } + | { type: "tool" | "tool_update"; toolData: ToolData } + | { type: "plan"; entries: PlanEntry[] }; + +function parseSessionNotification( + notification: SessionNotification, +): ParsedNotification | null { const { update } = notification; if (!update?.sessionUpdate) { return null; @@ -60,12 +81,7 @@ function parseSessionNotification(notification: SessionNotification): { switch (update.sessionUpdate) { case "user_message_chunk": - case "agent_message_chunk": - // `agent_message` is the aggregated final message emitted by the server - // once a response is complete; the desktop treats it the same as a - // streaming chunk. Without this case the final answer is silently - // dropped and the spinner stays on forever. - case "agent_message": { + case "agent_message_chunk": { if (update.content?.type === "text") { return { type: @@ -75,6 +91,18 @@ function parseSessionNotification(notification: SessionNotification): { } return null; } + // `agent_message` is the aggregated final message emitted by the server + // once a response is complete. If we already received streaming chunks, + // this is a duplicate — replace pending text instead of appending. + case "agent_message": { + if (update.content?.type === "text") { + return { + type: "agent_complete" as const, + content: update.content.text, + }; + } + return null; + } case "agent_thought_chunk": { if (update.content?.type === "text") { return { type: "thought", content: update.content.text }; @@ -82,6 +110,9 @@ function parseSessionNotification(notification: SessionNotification): { return null; } case "tool_call": { + const meta = update._meta?.claudeCode; + const isAgent = + meta?.toolName === "Agent" || meta?.toolName === "Task"; return { type: "tool", toolData: { @@ -89,10 +120,13 @@ function parseSessionNotification(notification: SessionNotification): { toolCallId: update.toolCallId ?? "", status: mapToolStatus(update.status), args: update.rawInput, + isAgent, + parentToolCallId: meta?.parentToolCallId, }, }; } case "tool_call_update": { + const meta = update._meta?.claudeCode; return { type: "tool_update", toolData: { @@ -101,41 +135,113 @@ function parseSessionNotification(notification: SessionNotification): { status: mapToolStatus(update.status), args: update.rawInput, result: update.rawOutput, + parentToolCallId: meta?.parentToolCallId, }, }; } + case "plan": { + if (Array.isArray(update.entries)) { + return { type: "plan", entries: update.entries }; + } + return null; + } default: return null; } } -function processEvents(events: SessionEvent[]): ParsedMessage[] { - const messages: ParsedMessage[] = []; - let pendingAgentText = ""; - let pendingThoughtText = ""; - let agentMessageCount = 0; - let thoughtMessageCount = 0; - let userMessageCount = 0; - const toolMessages = new Map(); +interface ProcessedEvents { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; +} + +function isQuestionTool(toolData?: ToolData): boolean { + if (!toolData) return false; + if (toolData.toolName.toLowerCase().includes("question")) return true; + if (Array.isArray(toolData.args?.questions)) return true; + return false; +} + +// Mutable processor state persisted across renders via useRef. +// Only new events (past processedIdx) are processed on each call. +interface EventProcessorState { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; + pendingAgentText: string; + pendingThoughtText: string; + lastAgentMsgIdx: number | null; + agentMessageCount: number; + thoughtMessageCount: number; + userMessageCount: number; + toolMessages: Map; + // Maps agent toolCallId → agent ParsedMessage for nesting children + agentTools: Map; + processedIdx: number; + // Snapshot tracking: only create a new array ref when messages grow. + // Mutations (tool_update, agent_complete replacing content) reuse the + // same snapshot so FlatList doesn't re-layout and reset scroll position. + lastSnapshot: ParsedMessage[]; + lastSnapshotLength: number; +} + +function createProcessorState(): EventProcessorState { + return { + messages: [], + plan: null, + pendingAgentText: "", + pendingThoughtText: "", + lastAgentMsgIdx: null, + agentMessageCount: 0, + thoughtMessageCount: 0, + userMessageCount: 0, + toolMessages: new Map(), + agentTools: new Map(), + processedIdx: 0, + lastSnapshot: [], + lastSnapshotLength: 0, + }; +} + +function processNewEvents( + state: EventProcessorState, + events: SessionEvent[], +): ProcessedEvents { + // If events shrank (e.g. session reset), start fresh + if (events.length < state.processedIdx) { + Object.assign(state, createProcessorState()); + } + + // Nothing new to process + if (events.length === state.processedIdx) { + return { messages: state.messages, plan: state.plan }; + } const flushAgentText = () => { - if (!pendingAgentText) return; - messages.push({ - id: `agent-${agentMessageCount++}`, + if (!state.pendingAgentText) return; + const msg: ParsedMessage = { + id: `agent-${state.agentMessageCount++}`, type: "agent", - content: pendingAgentText, - }); - pendingAgentText = ""; + content: state.pendingAgentText, + }; + state.messages.push(msg); + state.lastAgentMsgIdx = state.messages.length - 1; + state.pendingAgentText = ""; }; const flushThoughtText = () => { - if (!pendingThoughtText) return; - messages.push({ - id: `thought-${thoughtMessageCount++}`, - type: "thought", - content: pendingThoughtText, - }); - pendingThoughtText = ""; + if (!state.pendingThoughtText) return; + // Merge consecutive thoughts into one message instead of many rows + const lastMsg = state.messages[state.messages.length - 1]; + if (lastMsg?.type === "thought") { + lastMsg.content += state.pendingThoughtText; + } else { + state.messages.push({ + id: `thought-${state.thoughtMessageCount++}`, + type: "thought", + content: state.pendingThoughtText, + }); + } + state.pendingThoughtText = ""; }; const flushPending = () => { @@ -143,7 +249,10 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { flushAgentText(); }; - for (const event of events) { + let hasItemMutation = false; + + for (let i = state.processedIdx; i < events.length; i++) { + const event = events[i]; if (event.type !== "session_update") continue; const parsed = parseSessionNotification(event.notification); @@ -152,46 +261,86 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { switch (parsed.type) { case "user": flushPending(); - messages.push({ - id: `user-${userMessageCount++}`, + state.messages.push({ + id: `user-${state.userMessageCount++}`, type: "user", content: parsed.content ?? "", }); + state.lastAgentMsgIdx = null; break; case "agent": flushThoughtText(); - pendingAgentText += parsed.content ?? ""; + state.pendingAgentText += parsed.content ?? ""; + break; + case "agent_complete": + flushThoughtText(); + // If we already flushed an agent message from chunks, replace it + if ( + state.lastAgentMsgIdx !== null && + state.messages[state.lastAgentMsgIdx]?.type === "agent" + ) { + state.messages[state.lastAgentMsgIdx].content = parsed.content ?? ""; + state.pendingAgentText = ""; + } else { + state.pendingAgentText = parsed.content ?? ""; + } break; case "thought": flushAgentText(); - pendingThoughtText += parsed.content ?? ""; + state.pendingThoughtText += parsed.content ?? ""; + break; + case "plan": + state.plan = parsed.entries; break; case "tool": flushPending(); if (parsed.toolData) { - const existing = toolMessages.get(parsed.toolData.toolCallId); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); if (existing?.toolData) { - // Duplicate tool_call — refresh fields on the existing message - // in place instead of pushing a second entry with a colliding key. - existing.toolData = { ...existing.toolData, ...parsed.toolData }; + existing.toolData = { + ...existing.toolData, + ...parsed.toolData, + }; } else { const msg: ParsedMessage = { id: `tool-${parsed.toolData.toolCallId}`, type: "tool", content: "", toolData: parsed.toolData, + children: parsed.toolData.isAgent ? [] : undefined, }; - toolMessages.set(parsed.toolData.toolCallId, msg); - messages.push(msg); + state.toolMessages.set(parsed.toolData.toolCallId, msg); + + // Agent tools: register for child nesting + if (parsed.toolData.isAgent) { + state.agentTools.set(parsed.toolData.toolCallId, msg); + } + + // Child tools: nest under parent agent instead of top-level + const parentId = parsed.toolData.parentToolCallId; + const parent = parentId + ? state.agentTools.get(parentId) + : undefined; + if (parent?.children) { + parent.children.push(msg); + hasItemMutation = true; + } else { + state.messages.push(msg); + } } } + state.lastAgentMsgIdx = null; break; case "tool_update": if (parsed.toolData) { - const existing = toolMessages.get(parsed.toolData.toolCallId); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); if (existing?.toolData) { existing.toolData.status = parsed.toolData.status; existing.toolData.result = parsed.toolData.result; + if (parsed.toolData.args) { + existing.toolData.args = parsed.toolData.args; + } + hasItemMutation = true; } } break; @@ -199,17 +348,187 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { } flushPending(); - return messages; + state.processedIdx = events.length; + + // Create a new array reference when messages were added or when a tool + // received args for the first time (so the diff view can render). + // Pure status/text mutations reuse the prior snapshot to avoid jumps. + if (state.messages.length !== state.lastSnapshotLength || hasItemMutation) { + state.lastSnapshot = [...state.messages]; + state.lastSnapshotLength = state.messages.length; + } + + return { messages: state.lastSnapshot, plan: state.plan }; +} + +function CollapsedThought({ content }: { content: string }) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + + return ( + setExpanded(!expanded)} className="px-4 py-0.5"> + + + Thought + + {expanded && ( + + {content} + + )} + + ); +} + +function agentPromptSummary(args?: Record): string | null { + if (!args) return null; + const prompt = + typeof args.prompt === "string" + ? args.prompt + : typeof args.description === "string" + ? args.description + : null; + if (!prompt) return null; + // Take the first meaningful line, truncated + const firstLine = prompt.split("\n").find((l) => l.trim())?.trim(); + if (!firstLine) return null; + return firstLine.length > 120 + ? `${firstLine.slice(0, 120)}…` + : firstLine; +} + +function AgentToolCard({ + item, + onOpenTask, +}: { + item: ParsedMessage; + onOpenTask?: (taskId: string) => void; +}) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + const toolData = item.toolData; + const children = item.children ?? []; + if (!toolData) return null; + + const isLoading = + toolData.status === "pending" || toolData.status === "running"; + const isFailed = toolData.status === "error"; + const childCount = children.length; + const subtitle = agentPromptSummary(toolData.args); + + return ( + + {/* Header */} + setExpanded(!expanded)} + className="px-3 py-2" + > + + {isLoading ? ( + + ) : ( + + )} + + {toolData.toolName} + + {childCount > 0 && ( + + {childCount} {childCount === 1 ? "tool" : "tools"} + + )} + {isFailed && ( + + Failed + + )} + + + {subtitle && ( + + {subtitle} + + )} + + + {/* Nested tool calls */} + {expanded && children.length > 0 && ( + + {children.map((child) => { + if (!child.toolData) return null; + return ( + + ); + })} + + )} + + ); } export function TaskSessionView({ events, - isPromptPending, onOpenTask, + onSendAnswer, contentContainerStyle, }: TaskSessionViewProps) { - const messages = useMemo(() => processEvents(events), [events]); + const processorRef = useRef(createProcessorState()); + const { messages, plan } = useMemo( + () => processNewEvents(processorRef.current, events), + [events], + ); + // Inverted FlatList renders data[0] at the visual bottom. + // Reverse so newest messages are at index 0 = bottom. + const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); + const flatListRef = useRef(null); + const buttonRef = useRef(null); + const isScrolledRef = useRef(false); + + const scrollToBottom = useCallback(() => { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, []); + + const handleScroll = useCallback( + (e: { nativeEvent: { contentOffset: { y: number } } }) => { + const scrolled = e.nativeEvent.contentOffset.y > 0; + if (scrolled !== isScrolledRef.current) { + isScrolledRef.current = scrolled; + buttonRef.current?.setNativeProps({ + style: { + opacity: scrolled ? 1 : 0, + pointerEvents: scrolled ? "auto" : "none", + }, + }); + } + }, + [], + ); const renderMessage = useCallback( ({ item }: { item: ParsedMessage }) => { @@ -221,53 +540,78 @@ export function TaskSessionView({ ); case "thought": - return ( - - ); + return ; case "tool": - return item.toolData ? ( + if (!item.toolData) return null; + if (isQuestionTool(item.toolData)) { + return ( + + ); + } + if (item.toolData.isAgent) { + return ( + + ); + } + return ( - ) : null; + ); default: return null; } }, - [onOpenTask], + [onOpenTask, onSendAnswer], ); return ( - item.id} - inverted - contentContainerStyle={{ - flexDirection: "column-reverse", - ...contentContainerStyle, - }} - keyboardDismissMode="interactive" - keyboardShouldPersistTaps="handled" - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - isPromptPending ? ( - - - - Thinking... - - - ) : null - } - /> + + + item.id} + inverted + contentContainerStyle={contentContainerStyle} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator + onScroll={handleScroll} + scrollEventThrottle={100} + maxToRenderPerBatch={15} + windowSize={21} + initialNumToRender={30} + /> + + + + + + ); } diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 44a57310d..eeab9028e 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; import { CloudCommandError, @@ -8,11 +9,56 @@ import { runTaskInCloud, sendCloudCommand, } from "../api"; -import type { SessionEvent, SessionNotification, Task } from "../types"; +import type { + SessionEvent, + SessionNotification, + StoredLogEntry, + Task, +} from "../types"; import { convertRawEntriesToEvents, parseSessionLogs, } from "../utils/parseSessionLogs"; +import { playMeepSound } from "../utils/sounds"; + +// Infer whether the agent is actively working or idle (waiting for user input). +// Primary signal: _posthog/turn_complete or _posthog/task_complete in raw log +// entries. Fallback: session update notification heuristic for older logs. +function inferAgentIsIdle( + rawEntries: StoredLogEntry[], + notifications: SessionNotification[], +): boolean { + // Check raw entries for explicit turn/task completion signals + for (let i = rawEntries.length - 1; i >= 0; i--) { + const method = rawEntries[i].notification?.method; + if ( + method === "_posthog/turn_complete" || + method === "_posthog/task_complete" + ) { + return true; + } + // If we hit a client-direction entry (user message), the agent hasn't + // completed a turn since the last user input. + if (rawEntries[i].direction === "client") break; + } + + // Fallback: check session update notifications for agent responses + for (let i = notifications.length - 1; i >= 0; i--) { + const su = notifications[i].update?.sessionUpdate; + if (su === "agent_message" || su === "agent_message_chunk") { + return true; + } + if ( + su === "user_message_chunk" || + su === "tool_call" || + su === "tool_call_update" || + su === "agent_thought_chunk" + ) { + return false; + } + } + return false; +} const CLOUD_POLLING_INTERVAL_MS = 500; @@ -32,6 +78,10 @@ export interface TaskSession { // poller so the UI can surface "Run failed" / "Run completed". terminalStatus?: "failed" | "completed"; lastError?: string | null; + // True when the user initiated work (new task, sendPrompt, resume) and + // we should play a sound when control returns. False when reconnecting + // to an already-running task to avoid spurious pings. + awaitingPing?: boolean; } interface TaskSessionStore { @@ -55,6 +105,10 @@ interface TaskSessionStore { const cloudPollers = new Map>(); const connectAttempts = new Set(); +// Guard against overlapping poll ticks — if a fetch takes >500ms, the next +// interval fires while the previous is still running, causing both to read +// the same processedLineCount and produce duplicate events. +const pollInFlight = new Set(); // Tick counts per task run used to throttle backend task-run status polling. const pollTicks = new Map(); // How many S3 polling ticks between each backend task-run status check. @@ -118,6 +172,7 @@ export const useTaskSessionStore = create((set, get) => ({ isPromptPending: true, // Agent is processing initial task logUrl: newLogUrl, processedLineCount: 0, + awaitingPing: true, }, }, })); @@ -148,10 +203,9 @@ export const useTaskSessionStore = create((set, get) => ({ taskDescription, ); - // Source of truth for "is the agent still working" is the backend run - // status, not a heuristic over the log shape. A completed/failed run - // must NOT show the "Thinking..." indicator even if the last log entry - // isn't a recognized agent-response type. + // Terminal runs (completed/failed) always clear isPromptPending. + // For non-terminal runs we infer idle vs working from the log shape + // because the backend has no "waiting_for_input" status. const backendStatus = task.latest_run?.status; const isTerminal = backendStatus === "completed" || backendStatus === "failed"; @@ -170,7 +224,9 @@ export const useTaskSessionStore = create((set, get) => ({ taskId, events: historicalEvents, status: "connected", - isPromptPending: !isTerminal, + isPromptPending: isTerminal + ? false + : !inferAgentIsIdle(rawEntries, notifications), logUrl: latestRunLogUrl, processedLineCount: rawEntries.length, terminalStatus, @@ -239,6 +295,7 @@ export const useTaskSessionStore = create((set, get) => ({ events: [...current.events, userEvent], localUserEchoes: nextLocalEchoes, isPromptPending: true, + awaitingPing: true, }, }, }; @@ -345,6 +402,10 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Starting cloud S3 polling", { taskRunId }); const pollS3 = async () => { + // Skip if previous tick is still in flight + if (pollInFlight.has(taskRunId)) return; + pollInFlight.add(taskRunId); + try { const session = get().sessions[taskRunId]; if (!session) { @@ -352,12 +413,13 @@ export const useTaskSessionStore = create((set, get) => ({ return; } - // Periodically check the backend task-run status as a safety net - // for runs that never write anything to the S3 log (e.g. failed - // pre-agent-start). This prevents "stuck on Thinking..." forever. + // Check backend status periodically, or every tick while the agent + // is pending (so "Thinking..." clears promptly when the run finishes). const tick = (pollTicks.get(taskRunId) ?? 0) + 1; pollTicks.set(taskRunId, tick); - if (tick % STATUS_CHECK_TICK_INTERVAL === 0) { + const shouldCheckStatus = + session.isPromptPending || tick % STATUS_CHECK_TICK_INTERVAL === 0; + if (shouldCheckStatus) { try { const run = await getTaskRun(session.taskId, taskRunId); logger.debug("Status check", { @@ -371,6 +433,8 @@ export const useTaskSessionStore = create((set, get) => ({ status: run.status, error: run.error_message, }); + const shouldPing = + get().sessions[taskRunId]?.awaitingPing ?? false; set((state) => { const current = state.sessions[taskRunId]; if (!current) return state; @@ -382,10 +446,17 @@ export const useTaskSessionStore = create((set, get) => ({ isPromptPending: false, terminalStatus: run.status as "failed" | "completed", lastError: run.error_message, + awaitingPing: false, }, }, }; }); + if ( + shouldPing && + usePreferencesStore.getState().pingsEnabled + ) { + playMeepSound().catch(() => {}); + } } } catch (statusErr) { logger.warn("Failed to fetch task run status", { @@ -409,7 +480,9 @@ export const useTaskSessionStore = create((set, get) => ({ }); const currentHashes = new Set(session.processedHashes ?? []); const remainingLocalEchoes = new Set(session.localUserEchoes ?? []); - + // Collect all new events in a batch, then do a single store + // update. This prevents N re-renders per poll tick. + const batchedEvents: SessionEvent[] = []; let receivedAgentMessage = false; for (const line of newLines) { @@ -419,33 +492,34 @@ export const useTaskSessionStore = create((set, get) => ({ ? new Date(entry.timestamp).getTime() : Date.now(); - const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}-${entry.direction ?? ""}`; + // Build a dedup hash specific enough to distinguish different + // events at the same timestamp. For session/update entries, + // include the update type, toolCallId, and status so that a + // tool_call and its tool_call_update don't collide. + const params = entry.notification?.params; + const suDetail = params?.update + ? `-${params.update.sessionUpdate ?? ""}-${params.update.toolCallId ?? ""}-${params.update.status ?? ""}` + : `-${entry.direction ?? ""}`; + const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}${suDetail}`; if (currentHashes.has(hash)) { continue; } currentHashes.add(hash); - const acpEvent: SessionEvent = { + batchedEvents.push({ type: "acp_message", direction: entry.direction ?? "agent", ts, message: entry.notification, - }; - get()._handleEvent(taskRunId, acpEvent); + }); - // Terminal notifications from the agent — when the run - // completes or errors, clear the pending indicator so the - // UI doesn't stay stuck on "Thinking...". if ( entry.type === "notification" && - (entry.notification?.method === "_posthog/task_complete" || + (entry.notification?.method === "_posthog/turn_complete" || + entry.notification?.method === "_posthog/task_complete" || entry.notification?.method === "_posthog/error") ) { receivedAgentMessage = true; - logger.debug("Received terminal notification", { - taskRunId, - method: entry.notification.method, - }); } if ( @@ -456,9 +530,6 @@ export const useTaskSessionStore = create((set, get) => ({ const params = entry.notification.params as SessionNotification; const sessionUpdate = params?.update?.sessionUpdate; - // If this is a user_message_chunk that matches a locally - // echoed prompt, consume the echo and skip — prevents the - // prompt from rendering twice. if (sessionUpdate === "user_message_chunk") { const text = params?.update?.content?.text; if (text && remainingLocalEchoes.has(text)) { @@ -467,48 +538,61 @@ export const useTaskSessionStore = create((set, get) => ({ } } - const sessionUpdateEvent: SessionEvent = { + batchedEvents.push({ type: "session_update", ts, notification: params, - }; - get()._handleEvent(taskRunId, sessionUpdateEvent); - - // Check if this is an agent message - means agent is - // responding. `agent_message` is the aggregated final - // message emitted by the server once a response completes - // (as opposed to streaming `agent_message_chunk` frames). - if ( - sessionUpdate === "agent_message_chunk" || - sessionUpdate === "agent_message" || - sessionUpdate === "agent_thought_chunk" - ) { - receivedAgentMessage = true; - } + }); + + // Note: agent_message_chunk / agent_thought_chunk are NOT + // turn-completion signals — the agent streams them mid-turn. + // Turn completion is signalled by _posthog/turn_complete above. } } catch { // Skip invalid JSON } } - set((state) => ({ - sessions: { - ...state.sessions, - [taskRunId]: { - ...state.sessions[taskRunId], - processedLineCount: lines.length, - processedHashes: currentHashes, - localUserEchoes: remainingLocalEchoes, - // Clear pending state when we receive agent response - isPromptPending: receivedAgentMessage - ? false - : (state.sessions[taskRunId]?.isPromptPending ?? false), + // Single store update for all new events + const shouldPingAfterBatch = + receivedAgentMessage && + (get().sessions[taskRunId]?.awaitingPing ?? false); + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + events: + batchedEvents.length > 0 + ? [...current.events, ...batchedEvents] + : current.events, + processedLineCount: lines.length, + processedHashes: currentHashes, + localUserEchoes: remainingLocalEchoes, + isPromptPending: receivedAgentMessage + ? false + : current.isPromptPending, + awaitingPing: receivedAgentMessage + ? false + : current.awaitingPing, + }, }, - }, - })); + }; + }); + if ( + shouldPingAfterBatch && + usePreferencesStore.getState().pingsEnabled + ) { + playMeepSound().catch(() => {}); + } } } catch (err) { logger.warn("Cloud polling error", { error: err }); + } finally { + pollInFlight.delete(taskRunId); } }; @@ -570,6 +654,7 @@ export const useTaskSessionStore = create((set, get) => ({ isPromptPending: true, processedLineCount: 0, processedHashes: new Set(), + awaitingPing: true, }, }, }; diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 4c1578a4b..4ee8bb75e 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -51,9 +51,22 @@ export interface SessionNotification { status?: "pending" | "in_progress" | "completed" | "failed" | null; rawInput?: Record; rawOutput?: unknown; + entries?: PlanEntry[]; + _meta?: { + claudeCode?: { + toolName?: string; + parentToolCallId?: string; + }; + }; }; } +export interface PlanEntry { + content: string; + status: "pending" | "in_progress" | "completed"; + priority: string; +} + export interface AcpMessage { type: "acp_message"; direction: "client" | "agent"; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index fb405b1a6..8e380a8ad 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -61,7 +61,13 @@ export function convertRawEntriesToEvents( const events: SessionEvent[] = []; let notificationIdx = 0; - if (taskDescription) { + // Only prepend a synthetic user message when the logs don't already + // contain one (i.e. brand-new run with no log entries yet). Historical + // logs from S3 already include the original user_message_chunk. + const logsHaveUserMessage = notifications.some( + (n) => n.update?.sessionUpdate === "user_message_chunk", + ); + if (taskDescription && !logsHaveUserMessage) { const startTs = rawEntries[0]?.timestamp ? new Date(rawEntries[0].timestamp).getTime() - 1 : Date.now(); diff --git a/apps/mobile/src/features/tasks/utils/sounds.ts b/apps/mobile/src/features/tasks/utils/sounds.ts new file mode 100644 index 000000000..8460a0cb3 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/sounds.ts @@ -0,0 +1,23 @@ +import { Audio } from "expo-av"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const meepAsset = require("../../../../assets/sounds/meep.mp3"); + +let audioModeConfigured = false; + +export async function playMeepSound(): Promise { + if (!audioModeConfigured) { + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + }); + audioModeConfigured = true; + } + const { sound } = await Audio.Sound.createAsync(meepAsset, { + shouldPlay: true, + }); + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded && status.didJustFinish) { + sound.unloadAsync(); + } + }); +} diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index d8cabb956..41aa80ab1 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -291,10 +291,23 @@ async function handleAskUserQuestionTool( }, }); - if (context.signal?.aborted || response.outcome?.outcome === "cancelled") { + if (context.signal?.aborted) { throw new Error("Tool use aborted"); } + if (response.outcome?.outcome === "cancelled") { + const cancelMessage = ( + response._meta as Record | undefined + )?.message; + return { + behavior: "deny", + message: + typeof cancelMessage === "string" + ? cancelMessage + : "User cancelled the questions", + }; + } + if (response.outcome?.outcome !== "selected") { const customMessage = ( response._meta as Record | undefined diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index cb6f1ed29..8bd0ef694 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1315,20 +1315,101 @@ ${attributionInstructions} const selectedOptionId = allowOption?.optionId ?? params.options[0].optionId; - if (interactionOrigin === "slack") { - const codeToolKind = params.toolCall?._meta?.codeToolKind; - if (codeToolKind === "question") { - this.relaySlackQuestion(payload, params.toolCall?._meta); - return { - outcome: { outcome: "cancelled" as const }, - _meta: { - message: - "This question has been relayed to the Slack thread where this task originated. " + - "The user will reply there. Do NOT re-ask the question or pick an answer yourself. " + - "Simply let the user know you are waiting for their reply.", + const codeToolKind = params.toolCall?._meta?.codeToolKind; + + if (interactionOrigin === "slack" && codeToolKind === "question") { + this.relaySlackQuestion(payload, params.toolCall?._meta); + return { + outcome: { outcome: "cancelled" as const }, + _meta: { + message: + "This question has been relayed to the Slack thread where this task originated. " + + "The user will reply there. Do NOT re-ask the question or pick an answer yourself. " + + "Simply let the user know you are waiting for their reply.", + }, + }; + } + + // Interactive sessions (mobile/web): don't auto-approve questions. + // Log the question as in-progress so the client renders it interactively, + // then return cancelled with a wait message so the agent waits for the + // user's reply (same pattern as the Slack relay above). + if ( + mode === "interactive" && + codeToolKind === "question" && + interactionOrigin !== "slack" && + params.toolCall?._meta + ) { + const questionMeta = params.toolCall._meta; + const toolCallId = `question-${Date.now()}`; + + const questionNotification = { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + title: "AskUserQuestion", + toolCallId, + status: "in_progress", + rawInput: questionMeta, }, - }; - } + }, + }; + + this.broadcastEvent({ + type: "notification", + timestamp: new Date().toISOString(), + notification: questionNotification, + }); + + this.session?.logWriter.appendRawLine( + payload.run_id, + JSON.stringify(questionNotification), + ); + + return { + outcome: { outcome: "cancelled" as const }, + _meta: { + message: + "This question has been sent to the user's device. " + + "The user will reply with their selection. Do NOT re-ask the question or pick an answer yourself. " + + "Wait for the user's reply.", + }, + }; + } + + // Background mode or non-question permissions: log as completed and auto-approve + if (codeToolKind === "question" && params.toolCall?._meta) { + const questionMeta = params.toolCall._meta; + const toolCallId = `question-${Date.now()}`; + const selectedOption = allowOption?.name ?? "Auto-approved"; + + const questionNotification = { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + title: "AskUserQuestion", + toolCallId, + status: "completed", + rawInput: questionMeta, + rawOutput: { answer: selectedOption }, + }, + }, + }; + + this.broadcastEvent({ + type: "notification", + timestamp: new Date().toISOString(), + notification: questionNotification, + }); + + this.session?.logWriter.appendRawLine( + payload.run_id, + JSON.stringify(questionNotification), + ); } return { diff --git a/packages/agent/src/server/question-relay.test.ts b/packages/agent/src/server/question-relay.test.ts index 5f73abfd3..af38d48bf 100644 --- a/packages/agent/src/server/question-relay.test.ts +++ b/packages/agent/src/server/question-relay.test.ts @@ -225,7 +225,7 @@ describe("Question relay", () => { delete process.env.CODE_INTERACTION_ORIGIN; }); - it("auto-approves question tools (no Slack relay)", async () => { + it("returns cancelled with wait message for interactive question tools", async () => { const client = server.createCloudClient(TEST_PAYLOAD); const result = await client.requestPermission({ @@ -233,7 +233,10 @@ describe("Question relay", () => { toolCall: { _meta: QUESTION_META }, }); - expect(result.outcome.outcome).toBe("selected"); + expect(result.outcome.outcome).toBe("cancelled"); + expect(result._meta?.message).toContain( + "sent to the user's device", + ); }); it("keeps auto-approving permissions after SSE send failures", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 477ba393e..cedd84aac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -572,6 +572,9 @@ importers: expo-secure-store: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33) + expo-speech-recognition: + specifier: ^3.1.2 + version: 3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-splash-screen: specifier: ~31.0.12 version: 31.0.13(expo@54.0.33) @@ -6932,6 +6935,13 @@ packages: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} + expo-speech-recognition@3.1.2: + resolution: {integrity: sha512-yaXy+6w218Urdshits2KsfLjXNCnGNlXzUxEP4BVehKEbiIPAeUKBzuicCeELU5H2zTLwL9u+RjbFAUom4LiYQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-splash-screen@31.0.13: resolution: {integrity: sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==} peerDependencies: @@ -18393,6 +18403,12 @@ snapshots: expo-server@1.0.5: {} + expo-speech-recognition@3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-splash-screen@31.0.13(expo@54.0.33): dependencies: '@expo/prebuild-config': 54.0.8(expo@54.0.33) From 307915a046f126f055c783d7017dc7ca78194b3f Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 18:25:56 +0100 Subject: [PATCH 09/39] WIP COMMIT --- apps/mobile/src/app/task/[id].tsx | 1 + .../features/chat/components/MarkdownText.tsx | 76 +++++++++++- .../tasks/components/TaskSessionView.tsx | 108 +++++++++++++++++- .../features/tasks/stores/taskSessionStore.ts | 77 ++++++++++--- packages/agent/src/server/agent-server.ts | 25 ++-- 5 files changed, 251 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 9d47cb68c..6e082edff 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -158,6 +158,7 @@ export default function TaskDetailScreen() { switching from loading spinner to rendered content. */} cell.trim()); + // Skip the separator row + if (!/^[\s-:|]+$/.test(lines[i].replace(/\|/g, ""))) { + rows.push(row); + } + i++; + } + if (rows.length > 0) { + blocks.push({ type: "table", content: "", rows }); + } + continue; + } + // Empty line if (line.trim() === "") { i++; @@ -84,7 +106,8 @@ function parseBlocks(text: string): Block[] { !lines[i].startsWith("```") && !lines[i].match(/^#{1,3}\s/) && !/^\s*[-*]\s/.test(lines[i]) && - !/^\s*\d+[.)]\s/.test(lines[i]) + !/^\s*\d+[.)]\s/.test(lines[i]) && + !(lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s-:|]+\|/.test(lines[i + 1])) ) { paraLines.push(lines[i]); i++; @@ -201,6 +224,51 @@ export function MarkdownText({ content }: MarkdownTextProps) { ); + case "table": { + const rows = block.rows ?? []; + const header = rows[0]; + const body = rows.slice(1); + return ( + + + {header && ( + + {header.map((cell, ci) => ( + 0 ? { borderLeftWidth: 1, borderLeftColor: "#3333" } : undefined} + > + + {renderInline(cell)} + + + ))} + + )} + {body.map((row, ri) => ( + + {row.map((cell, ci) => ( + 0 ? { borderLeftWidth: 1, borderLeftColor: "#3333" } : undefined} + > + + {renderInline(cell)} + + + ))} + + ))} + + + ); + } + default: return ( diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 53faa1e28..fe0d3d0c5 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -4,7 +4,7 @@ import { CaretRight, Robot, } from "phosphor-react-native"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, @@ -26,6 +26,7 @@ import { QuestionCard } from "./QuestionCard"; interface TaskSessionViewProps { events: SessionEvent[]; + isConnecting?: boolean; onOpenTask?: (taskId: string) => void; onSendAnswer?: (answer: string) => void; contentContainerStyle?: object; @@ -43,7 +44,7 @@ interface ToolData { interface ParsedMessage { id: string; - type: "user" | "agent" | "thought" | "tool"; + type: "user" | "agent" | "thought" | "tool" | "connecting"; content: string; toolData?: ToolData; children?: ParsedMessage[]; @@ -380,6 +381,53 @@ function CollapsedThought({ content }: { content: string }) { ); } +// Detect objects like {"0":"E","1":"r","2":"r",...,"isError":true} — a string +// serialized as char-per-key (possibly with extra metadata keys mixed in). +function tryReassembleString(obj: Record): string | null { + const numericKeys = Object.keys(obj).filter((k) => /^\d+$/.test(k)); + if (numericKeys.length < 3) return null; + if (numericKeys.every((k) => typeof obj[k] === "string" && (obj[k] as string).length === 1)) { + return numericKeys + .sort((a, b) => Number(a) - Number(b)) + .map((k) => obj[k]) + .join(""); + } + return null; +} + +function extractErrorText(result: unknown): string | null { + if (typeof result === "string") return result; + if (Array.isArray(result)) { + const texts = result.map(extractErrorText).filter(Boolean); + return texts.length > 0 ? texts.join("\n") : null; + } + if (!result || typeof result !== "object") return null; + const obj = result as Record; + + // Reassemble char-per-key strings: {"0":"E","1":"r",...} + const reassembled = tryReassembleString(obj); + if (reassembled) return reassembled; + + // Check simple string fields, recurse into nested objects + for (const key of ["error", "message", "stderr", "output", "text", "content"]) { + if (typeof obj[key] === "string") return obj[key] as string; + if (obj[key] && typeof obj[key] === "object") { + const nested = extractErrorText(obj[key]); + if (nested) return nested; + } + } + + // Last resort: stringify the result so *something* shows + try { + const str = JSON.stringify(result, null, 2); + if (str && str !== "{}") return str; + } catch { + // ignore + } + + return null; +} + function agentPromptSummary(args?: Record): string | null { if (!args) return null; const prompt = @@ -415,6 +463,7 @@ function AgentToolCard({ const isFailed = toolData.status === "error"; const childCount = children.length; const subtitle = agentPromptSummary(toolData.args); + const errorText = isFailed ? extractErrorText(toolData.result) : null; return ( @@ -468,9 +517,19 @@ function AgentToolCard({ )} - {/* Nested tool calls */} - {expanded && children.length > 0 && ( + {/* Error message + nested tool calls */} + {expanded && ( + {errorText && ( + + + {errorText} + + + )} {children.map((child) => { if (!child.toolData) return null; return ( @@ -491,8 +550,35 @@ function AgentToolCard({ ); } +const CONNECTING_MESSAGE: ParsedMessage = { + id: "__connecting__", + type: "connecting", + content: "", +}; + +function ConnectingIndicator() { + const themeColors = useThemeColors(); + const [dots, setDots] = useState(1); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + Connecting{".".repeat(dots)} + + + ); +} + export function TaskSessionView({ events, + isConnecting, onOpenTask, onSendAnswer, contentContainerStyle, @@ -504,7 +590,17 @@ export function TaskSessionView({ ); // Inverted FlatList renders data[0] at the visual bottom. // Reverse so newest messages are at index 0 = bottom. - const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); + const displayMessages = useMemo(() => { + // Show "Connecting..." when pending and no agent activity yet + if (isConnecting && messages.every((m) => m.type === "user")) { + return [...messages, CONNECTING_MESSAGE]; + } + return messages; + }, [messages, isConnecting]); + const reversedMessages = useMemo( + () => [...displayMessages].reverse(), + [displayMessages], + ); const themeColors = useThemeColors(); const flatListRef = useRef(null); const buttonRef = useRef(null); @@ -541,6 +637,8 @@ export function TaskSessionView({ ); case "thought": return ; + case "connecting": + return ; case "tool": if (!item.toolData) return null; if (isQuestionTool(item.toolData)) { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index eeab9028e..83e07a6e9 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -173,6 +173,9 @@ export const useTaskSessionStore = create((set, get) => ({ logUrl: newLogUrl, processedLineCount: 0, awaitingPing: true, + localUserEchoes: taskDescription + ? new Set([taskDescription]) + : undefined, }, }, })); @@ -484,6 +487,9 @@ export const useTaskSessionStore = create((set, get) => ({ // update. This prevents N re-renders per poll tick. const batchedEvents: SessionEvent[] = []; let receivedAgentMessage = false; + // Track when a user_message_chunk arrives that wasn't sent from + // this device — means someone prompted from the desktop app. + let receivedExternalUserMessage = false; for (const line of newLines) { try { @@ -506,6 +512,27 @@ export const useTaskSessionStore = create((set, get) => ({ } currentHashes.add(hash); + // Check for local echo dedup BEFORE pushing any events for + // this entry — otherwise the acp_message duplicate gets in. + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification?.params + ) { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + const text = params?.update?.content?.text; + if (text && remainingLocalEchoes.has(text)) { + remainingLocalEchoes.delete(text); + continue; + } + // User message not from this device (e.g. desktop app) + receivedExternalUserMessage = true; + } + } + batchedEvents.push({ type: "acp_message", direction: entry.direction ?? "agent", @@ -530,36 +557,52 @@ export const useTaskSessionStore = create((set, get) => ({ const params = entry.notification.params as SessionNotification; const sessionUpdate = params?.update?.sessionUpdate; - if (sessionUpdate === "user_message_chunk") { - const text = params?.update?.content?.text; - if (text && remainingLocalEchoes.has(text)) { - remainingLocalEchoes.delete(text); - continue; - } - } - batchedEvents.push({ type: "session_update", ts, notification: params, }); - // Note: agent_message_chunk / agent_thought_chunk are NOT - // turn-completion signals — the agent streams them mid-turn. - // Turn completion is signalled by _posthog/turn_complete above. + // agent_message (finalized, non-chunk) is a reasonable proxy + // for turn completion — it's emitted once the full response + // is assembled. Chunks and thoughts fire mid-turn and are NOT + // reliable. The proper signal is _posthog/turn_complete but + // it's not yet written to S3 logs by the server. + if (sessionUpdate === "agent_message") { + receivedAgentMessage = true; + } } } catch { // Skip invalid JSON } } - // Single store update for all new events + // Determine if we should ping. If an external user message armed + // the ping in this same batch, honour it even though the store + // hasn't updated yet. + const wasAwaitingPing = + get().sessions[taskRunId]?.awaitingPing ?? false; const shouldPingAfterBatch = receivedAgentMessage && - (get().sessions[taskRunId]?.awaitingPing ?? false); + (wasAwaitingPing || receivedExternalUserMessage); set((state) => { const current = state.sessions[taskRunId]; if (!current) return state; + + // Determine isPromptPending: external user message starts work, + // turn/task completion ends it. + let nextIsPromptPending = current.isPromptPending; + if (receivedExternalUserMessage) nextIsPromptPending = true; + if (receivedAgentMessage) nextIsPromptPending = false; + + // awaitingPing: arm when work starts (even from another device), + // disarm when it completes and the ping fires. + let nextAwaitingPing = current.awaitingPing; + if (receivedExternalUserMessage && !current.awaitingPing) { + nextAwaitingPing = true; + } + if (receivedAgentMessage) nextAwaitingPing = false; + return { sessions: { ...state.sessions, @@ -572,12 +615,8 @@ export const useTaskSessionStore = create((set, get) => ({ processedLineCount: lines.length, processedHashes: currentHashes, localUserEchoes: remainingLocalEchoes, - isPromptPending: receivedAgentMessage - ? false - : current.isPromptPending, - awaitingPing: receivedAgentMessage - ? false - : current.awaitingPing, + isPromptPending: nextIsPromptPending, + awaitingPing: nextAwaitingPing, }, }, }; diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 8bd0ef694..ebe4554a4 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1732,18 +1732,27 @@ ${attributionInstructions} private broadcastTurnComplete(stopReason: string): void { if (!this.session) return; + const notification = { + jsonrpc: "2.0" as const, + method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE, + params: { + sessionId: this.session.acpSessionId, + stopReason, + }, + }; + this.broadcastEvent({ type: "notification", timestamp: new Date().toISOString(), - notification: { - jsonrpc: "2.0", - method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE, - params: { - sessionId: this.session.acpSessionId, - stopReason, - }, - }, + notification, }); + + // Persist to S3 log so mobile clients (which poll S3) can detect + // turn completion and trigger "your turn" indicators / sounds. + this.session.logWriter.appendRawLine( + this.session.payload.run_id, + JSON.stringify(notification), + ); } private broadcastEvent(event: Record): void { From 513f0d7d3efd2a28cd62e183d44a66f841ca8a44 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 19:38:14 +0100 Subject: [PATCH 10/39] WIP - Add queue and some thinking optimisations --- apps/mobile/src/app/task/[id].tsx | 8 +- .../src/features/chat/components/Composer.tsx | 9 +- .../tasks/components/TaskSessionView.tsx | 40 +++++++- .../features/tasks/stores/taskSessionStore.ts | 95 +++++++++++++++---- .../features/tasks/utils/parseSessionLogs.ts | 23 ----- 5 files changed, 125 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 6e082edff..bbd9b969d 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -133,8 +133,8 @@ export default function TaskDetailScreen() { presentation: "modal", headerRight: environment ? () => ( - @@ -147,7 +147,7 @@ export default function TaskDetailScreen() { > {environment === "cloud" ? "Cloud" : "Local"} - + ) : undefined, }} @@ -159,6 +159,7 @@ export default function TaskDetailScreen() { diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index f6f9f803a..0a1104389 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -18,6 +18,7 @@ interface ComposerProps { disabled?: boolean; placeholder?: string; isUserTurn?: boolean; + queuedCount?: number; } function PulsingBorder({ @@ -85,6 +86,7 @@ export function Composer({ disabled = false, placeholder = "Ask a question", isUserTurn = false, + queuedCount = 0, }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); @@ -119,6 +121,11 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; + const effectivePlaceholder = queuedCount > 0 + ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` + : !isUserTurn && !disabled + ? "Message will be queued..." + : placeholder; if (Platform.OS === "ios") { return ( @@ -174,7 +181,7 @@ export function Composer({ ? "Recording..." : isTranscribing ? "Transcribing..." - : placeholder + : effectivePlaceholder } placeholderTextColor={themeColors.gray[9]} editable={!disabled && !isRecording} diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index fe0d3d0c5..e81257c81 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -27,6 +27,7 @@ import { QuestionCard } from "./QuestionCard"; interface TaskSessionViewProps { events: SessionEvent[]; isConnecting?: boolean; + isThinking?: boolean; onOpenTask?: (taskId: string) => void; onSendAnswer?: (answer: string) => void; contentContainerStyle?: object; @@ -44,7 +45,7 @@ interface ToolData { interface ParsedMessage { id: string; - type: "user" | "agent" | "thought" | "tool" | "connecting"; + type: "user" | "agent" | "thought" | "tool" | "connecting" | "thinking"; content: string; toolData?: ToolData; children?: ParsedMessage[]; @@ -556,6 +557,31 @@ const CONNECTING_MESSAGE: ParsedMessage = { content: "", }; +const THINKING_MESSAGE: ParsedMessage = { + id: "__thinking__", + type: "thinking", + content: "", +}; + +function ThinkingIndicator() { + const [dots, setDots] = useState(1); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + Thinking{".".repeat(dots)} + + + ); +} + function ConnectingIndicator() { const themeColors = useThemeColors(); const [dots, setDots] = useState(1); @@ -579,6 +605,7 @@ function ConnectingIndicator() { export function TaskSessionView({ events, isConnecting, + isThinking, onOpenTask, onSendAnswer, contentContainerStyle, @@ -591,12 +618,17 @@ export function TaskSessionView({ // Inverted FlatList renders data[0] at the visual bottom. // Reverse so newest messages are at index 0 = bottom. const displayMessages = useMemo(() => { + const onlyUserMessages = messages.every((m) => m.type === "user"); // Show "Connecting..." when pending and no agent activity yet - if (isConnecting && messages.every((m) => m.type === "user")) { + if (isConnecting && onlyUserMessages) { return [...messages, CONNECTING_MESSAGE]; } + // Show "Thinking..." when agent is working (after initial connection) + if (isThinking && !onlyUserMessages) { + return [...messages, THINKING_MESSAGE]; + } return messages; - }, [messages, isConnecting]); + }, [messages, isConnecting, isThinking]); const reversedMessages = useMemo( () => [...displayMessages].reverse(), [displayMessages], @@ -639,6 +671,8 @@ export function TaskSessionView({ return ; case "connecting": return ; + case "thinking": + return ; case "tool": if (!item.toolData) return null; if (isQuestionTool(item.toolData)) { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 83e07a6e9..57b478134 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -82,6 +82,9 @@ export interface TaskSession { // we should play a sound when control returns. False when reconnecting // to an already-running task to avoid spurious pings. awaitingPing?: boolean; + // Messages queued while the agent is working. Auto-sent when control + // returns (isPromptPending flips to false). + messageQueue?: string[]; } interface TaskSessionStore { @@ -154,28 +157,12 @@ export const useTaskSessionStore = create((set, get) => ({ [newRunId]: { taskRunId: newRunId, taskId, - events: taskDescription - ? [ - { - type: "session_update" as const, - ts: Date.now(), - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }, - ] - : [], + events: [], status: "connected", - isPromptPending: true, // Agent is processing initial task + isPromptPending: true, logUrl: newLogUrl, processedLineCount: 0, awaitingPing: true, - localUserEchoes: taskDescription - ? new Set([taskDescription]) - : undefined, }, }, })); @@ -203,7 +190,6 @@ export const useTaskSessionStore = create((set, get) => ({ const historicalEvents = convertRawEntriesToEvents( rawEntries, notifications, - taskDescription, ); // Terminal runs (completed/failed) always clear isPromptPending. @@ -271,6 +257,25 @@ export const useTaskSessionStore = create((set, get) => ({ throw new Error("No active session for task"); } + // If the agent is still working, queue the message for later. + if (session.isPromptPending) { + logger.debug("Agent busy, queuing message", { taskId }); + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + messageQueue: [...(current.messageQueue ?? []), prompt], + }, + }, + }; + }); + return; + } + // Local echo for immediate UX feedback — polling will re-surface the // canonical copy once the agent writes it to the log; any duplicate is // removed by content-based dedup in the polling loop below. @@ -450,6 +455,7 @@ export const useTaskSessionStore = create((set, get) => ({ terminalStatus: run.status as "failed" | "completed", lastError: run.error_message, awaitingPing: false, + messageQueue: undefined, }, }, }; @@ -614,7 +620,10 @@ export const useTaskSessionStore = create((set, get) => ({ : current.events, processedLineCount: lines.length, processedHashes: currentHashes, - localUserEchoes: remainingLocalEchoes, + localUserEchoes: + remainingLocalEchoes.size > 0 + ? remainingLocalEchoes + : undefined, isPromptPending: nextIsPromptPending, awaitingPing: nextAwaitingPing, }, @@ -707,3 +716,49 @@ export const useTaskSessionStore = create((set, get) => ({ }); }, })); + +// Watch for isPromptPending transitions (true → false) and auto-send the +// next queued message. Uses setTimeout so state is fully settled before +// sendPrompt re-enters the store. +const drainInFlight = new Set(); +useTaskSessionStore.subscribe((state, prev) => { + for (const [runId, session] of Object.entries(state.sessions)) { + const prevSession = prev.sessions[runId]; + if ( + prevSession?.isPromptPending && + !session.isPromptPending && + !session.terminalStatus && + session.messageQueue?.length && + !drainInFlight.has(runId) + ) { + drainInFlight.add(runId); + setTimeout(async () => { + try { + const current = useTaskSessionStore.getState().sessions[runId]; + if (!current?.messageQueue?.length) return; + + const [next, ...rest] = current.messageQueue; + useTaskSessionStore.setState((s) => { + const sess = s.sessions[runId]; + if (!sess) return s; + return { + sessions: { + ...s.sessions, + [runId]: { + ...sess, + messageQueue: rest.length > 0 ? rest : undefined, + }, + }, + }; + }); + + await useTaskSessionStore.getState().sendPrompt(current.taskId, next); + } catch (err) { + logger.warn("Failed to send queued message", { runId, error: err }); + } finally { + drainInFlight.delete(runId); + } + }, 50); + } + } +}); diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index 8e380a8ad..307efca93 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -56,33 +56,10 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { export function convertRawEntriesToEvents( rawEntries: StoredLogEntry[], notifications: SessionNotification[], - taskDescription?: string, ): SessionEvent[] { const events: SessionEvent[] = []; let notificationIdx = 0; - // Only prepend a synthetic user message when the logs don't already - // contain one (i.e. brand-new run with no log entries yet). Historical - // logs from S3 already include the original user_message_chunk. - const logsHaveUserMessage = notifications.some( - (n) => n.update?.sessionUpdate === "user_message_chunk", - ); - if (taskDescription && !logsHaveUserMessage) { - const startTs = rawEntries[0]?.timestamp - ? new Date(rawEntries[0].timestamp).getTime() - 1 - : Date.now(); - events.push({ - type: "session_update", - ts: startTs, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }); - } - for (const entry of rawEntries) { const ts = entry.timestamp ? new Date(entry.timestamp).getTime() From 09ced135f3053ae63d3a162d7718470e3bf46ab2 Mon Sep 17 00:00:00 2001 From: Sandy Spicer Date: Wed, 15 Apr 2026 22:26:08 +0100 Subject: [PATCH 11/39] syncing --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + .../services/agent/local-command-receiver.ts | 234 ++++++++++++++++++ apps/code/src/main/services/agent/schemas.ts | 19 +- .../src/main/services/agent/service.test.ts | 6 + apps/code/src/main/services/agent/service.ts | 47 ++++ .../features/sessions/service/service.ts | 2 + apps/mobile/app.json | 4 +- apps/mobile/index.js | 1 + apps/mobile/package.json | 2 +- .../src/features/tasks/hooks/useTasks.ts | 1 - tsconfig.json | 4 + 12 files changed, 301 insertions(+), 22 deletions(-) create mode 100644 apps/code/src/main/services/agent/local-command-receiver.ts create mode 100644 apps/mobile/index.js create mode 100644 tsconfig.json diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index f45163c53..2a688ba2a 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -10,6 +10,7 @@ import { WorkspaceRepository } from "../db/repositories/workspace-repository"; import { WorktreeRepository } from "../db/repositories/worktree-repository"; import { DatabaseService } from "../db/service"; import { AgentAuthAdapter } from "../services/agent/auth-adapter"; +import { LocalCommandReceiver } from "../services/agent/local-command-receiver"; import { AgentService } from "../services/agent/service"; import { AppLifecycleService } from "../services/app-lifecycle/service"; import { ArchiveService } from "../services/archive/service"; @@ -64,6 +65,7 @@ container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository); container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl); container.bind(MAIN_TOKENS.AgentAuthAdapter).to(AgentAuthAdapter); container.bind(MAIN_TOKENS.AgentService).to(AgentService); +container.bind(MAIN_TOKENS.LocalCommandReceiver).to(LocalCommandReceiver); container.bind(MAIN_TOKENS.AuthService).to(AuthService); container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService); container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 27bdbcafc..5308d78f4 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -21,6 +21,7 @@ export const MAIN_TOKENS = Object.freeze({ // Services AgentAuthAdapter: Symbol.for("Main.AgentAuthAdapter"), AgentService: Symbol.for("Main.AgentService"), + LocalCommandReceiver: Symbol.for("Main.LocalCommandReceiver"), AuthService: Symbol.for("Main.AuthService"), AuthProxyService: Symbol.for("Main.AuthProxyService"), ArchiveService: Symbol.for("Main.ArchiveService"), diff --git a/apps/code/src/main/services/agent/local-command-receiver.ts b/apps/code/src/main/services/agent/local-command-receiver.ts new file mode 100644 index 000000000..432f00e2f --- /dev/null +++ b/apps/code/src/main/services/agent/local-command-receiver.ts @@ -0,0 +1,234 @@ +import { inject, injectable, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import type { AuthService } from "../auth/service"; + +const log = logger.scope("local-command-receiver"); +const RECONNECT_DELAY_MS = 2000; + +/** + * JSON-RPC envelope carried inside an `incoming_command` SSE event. The + * backend repackages whatever mobile POSTs to /command/ into this shape + * (see products/tasks/backend/api.py :: command). + */ +export interface IncomingCommandPayload { + jsonrpc: string; + method: string; + params?: { content?: string } & Record; + id?: string | number; +} + +interface SubscribeParams { + taskId: string; + taskRunId: string; + projectId: number; + apiHost: string; + onCommand: (payload: IncomingCommandPayload) => Promise; +} + +interface Subscription { + taskRunId: string; + controller: AbortController; +} + +/** + * Subscribes to the PostHog task-run SSE stream for a local run and + * delivers `incoming_command` events (published by the backend when mobile + * POSTs to /command/ on a run with environment=local) to a caller-supplied + * callback. + * + * One SSE connection per subscribed run. Reconnects with backoff on failure. + * Uses the `Last-Event-ID` header to resume from the last processed event + * so brief network blips don't drop commands. + */ +@injectable() +export class LocalCommandReceiver { + private readonly subs = new Map(); + + constructor( + @inject(MAIN_TOKENS.AuthService) + private readonly auth: AuthService, + ) {} + + subscribe(params: SubscribeParams): void { + if (this.subs.has(params.taskRunId)) { + log.debug("Already subscribed", { taskRunId: params.taskRunId }); + return; + } + const controller = new AbortController(); + this.subs.set(params.taskRunId, { + taskRunId: params.taskRunId, + controller, + }); + log.info("Subscribing to SSE stream", { taskRunId: params.taskRunId }); + void this.connectLoop(params, controller).catch((err) => { + if (controller.signal.aborted) return; + log.error("Connect loop exited unexpectedly", { + taskRunId: params.taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + unsubscribe(taskRunId: string): void { + const sub = this.subs.get(taskRunId); + if (!sub) return; + sub.controller.abort(); + this.subs.delete(taskRunId); + log.info("Unsubscribed", { taskRunId }); + } + + @preDestroy() + async shutdown(): Promise { + // Abort before awaiting teardown — per async-cleanup-ordering guidance. + for (const sub of this.subs.values()) sub.controller.abort(); + this.subs.clear(); + } + + private async connectLoop( + params: SubscribeParams, + controller: AbortController, + ): Promise { + let lastEventId: string | undefined; + + while (!controller.signal.aborted) { + try { + const { accessToken } = await this.auth.getValidAccessToken(); + const url = new URL( + `${params.apiHost}/api/projects/${params.projectId}/tasks/${params.taskId}/runs/${params.taskRunId}/stream/`, + ); + if (!lastEventId) { + // Fresh connect: only care about events published from now on. + // On reconnect we use Last-Event-ID instead (see headers below). + url.searchParams.set("start", "latest"); + } + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "text/event-stream", + }; + if (lastEventId) headers["Last-Event-ID"] = lastEventId; + + const response = await fetch(url.toString(), { + headers, + signal: controller.signal, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `SSE HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`, + ); + } + + lastEventId = await this.readEventStream( + response.body, + params.onCommand, + controller.signal, + lastEventId, + ); + log.info("SSE stream ended cleanly", { + taskRunId: params.taskRunId, + }); + } catch (err) { + if (controller.signal.aborted) return; + log.warn("SSE disconnected, will reconnect", { + taskRunId: params.taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + } + if (controller.signal.aborted) return; + await this.sleep(RECONNECT_DELAY_MS, controller.signal); + } + } + + private async readEventStream( + body: ReadableStream | null, + onCommand: SubscribeParams["onCommand"], + signal: AbortSignal, + seedLastEventId: string | undefined, + ): Promise { + if (!body) throw new Error("Missing SSE response body"); + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastEventId = seedLastEventId; + + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) return lastEventId; + buffer += decoder.decode(value, { stream: true }); + + // SSE event blocks are separated by a blank line (\n\n). + while (true) { + const separator = buffer.indexOf("\n\n"); + if (separator === -1) break; + const rawEvent = buffer.slice(0, separator); + buffer = buffer.slice(separator + 2); + + let dataChunks = ""; + let eventId: string | undefined; + for (const line of rawEvent.split("\n")) { + if (line.startsWith("data: ")) { + dataChunks += line.slice(6); + } else if (line.startsWith("id: ")) { + eventId = line.slice(4); + } + // `event:` and comments are ignored — we route on data.type. + } + if (!dataChunks) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(dataChunks); + } catch { + log.warn("Failed to parse SSE data chunk", { + preview: dataChunks.slice(0, 120), + }); + continue; + } + + if ( + typeof parsed === "object" && + parsed !== null && + (parsed as { type?: unknown }).type === "incoming_command" + ) { + const payload = (parsed as { payload?: unknown }).payload; + if (payload && typeof payload === "object") { + try { + await onCommand(payload as IncomingCommandPayload); + } catch (err) { + log.error("Incoming command handler threw", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + if (eventId) lastEventId = eventId; + } + } + return lastEventId; + } finally { + try { + await reader.cancel(); + } catch { + // Reader already closed or cancelled; nothing to do. + } + } + } + + private sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + } +} diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 5ad875f8f..ab3f62ee7 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -16,24 +16,6 @@ export const credentialsSchema = z.object({ export type Credentials = z.infer; -// Session config schema -export const sessionConfigSchema = z.object({ - taskId: z.string(), - taskRunId: z.string(), - repoPath: z.string(), - credentials: credentialsSchema, - logUrl: z.string().optional(), - /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ - sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), - /** Additional directories Claude can access beyond cwd (for worktree support) */ - additionalDirectories: z.array(z.string()).optional(), - /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ - permissionMode: z.string().optional(), -}); - -export type SessionConfig = z.infer; - // Start session input/output export const startSessionInput = z.object({ @@ -173,6 +155,7 @@ export const reconnectSessionInput = z.object({ permissionMode: z.string().optional(), customInstructions: z.string().max(2000).optional(), effort: effortLevelSchema.optional(), + runMode: z.enum(["local", "cloud"]).optional(), }); export type ReconnectSessionInput = z.infer; diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 02e1642cc..cad708898 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -176,6 +176,11 @@ function createMockDependencies() { notifyToolResult: vi.fn(), notifyToolCancelled: vi.fn(), }, + localCommandReceiver: { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, }; } @@ -201,6 +206,7 @@ describe("AgentService", () => { deps.posthogPluginService as never, deps.agentAuthAdapter as never, deps.mcpAppsService as never, + deps.localCommandReceiver as never, ); }); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index b03024eb5..3f24acfe4 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -51,6 +51,7 @@ import type { ProcessTrackingService } from "../process-tracking/service"; import type { SleepService } from "../sleep/service"; import type { AgentAuthAdapter } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; +import type { LocalCommandReceiver } from "./local-command-receiver"; import { AgentServiceEvent, type AgentServiceEvents, @@ -255,6 +256,8 @@ interface SessionConfig { effort?: EffortLevel; /** Model to use for the session (e.g. "claude-sonnet-4-6") */ model?: string; + /** Whether this session runs locally on the desktop or in a cloud sandbox */ + runMode?: "local" | "cloud"; } interface ManagedSession { @@ -322,6 +325,7 @@ export class AgentService extends TypedEventEmitter { private posthogPluginService: PosthogPluginService; private agentAuthAdapter: AgentAuthAdapter; private mcpAppsService: McpAppsService; + private localCommandReceiver: LocalCommandReceiver; constructor( @inject(MAIN_TOKENS.ProcessTrackingService) @@ -336,6 +340,8 @@ export class AgentService extends TypedEventEmitter { agentAuthAdapter: AgentAuthAdapter, @inject(MAIN_TOKENS.McpAppsService) mcpAppsService: McpAppsService, + @inject(MAIN_TOKENS.LocalCommandReceiver) + localCommandReceiver: LocalCommandReceiver, ) { super(); this.processTracking = processTracking; @@ -344,6 +350,7 @@ export class AgentService extends TypedEventEmitter { this.posthogPluginService = posthogPluginService; this.agentAuthAdapter = agentAuthAdapter; this.mcpAppsService = mcpAppsService; + this.localCommandReceiver = localCommandReceiver; powerMonitor.on("resume", () => this.checkIdleDeadlines()); } @@ -809,6 +816,40 @@ When creating pull requests, add the following footer at the end of the PR descr this.sessions.set(taskRunId, session); this.recordActivity(taskRunId); + if (config.runMode === "local") { + // Subscribe to the task-run SSE stream so mobile-originated + // /command/ calls (published to Redis by the backend) are + // delivered into this local session as prompts. + this.localCommandReceiver.subscribe({ + taskId, + taskRunId, + projectId: credentials.projectId, + apiHost: credentials.apiHost, + onCommand: async (payload) => { + if (payload.method !== "user_message") { + log.debug("Ignoring non-user_message local command", { + method: payload.method, + taskRunId, + }); + return; + } + const content = payload.params?.content; + if (typeof content !== "string" || content.length === 0) { + log.warn("Local command missing content", { taskRunId }); + return; + } + try { + await this.prompt(taskRunId, [{ type: "text", text: content }]); + } catch (err) { + log.error("Failed to deliver local command to session", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + }); + } + if (isRetry) { log.info("Session created after auth retry", { taskRunId }); } @@ -1149,6 +1190,11 @@ For git operations while detached: } private async cleanupSession(taskRunId: string): Promise { + // Abort any outstanding SSE subscription for this run first — per + // async-cleanup-ordering guidance, we release external resources + // before awaiting anything that depends on the session being gone. + this.localCommandReceiver.unsubscribe(taskRunId); + const session = this.sessions.get(taskRunId); if (session) { this.cancelInFlightMcpToolCalls(session); @@ -1470,6 +1516,7 @@ For git operations while detached: "customInstructions" in params ? params.customInstructions : undefined, effort: "effort" in params ? params.effort : undefined, model: "model" in params ? params.model : undefined, + runMode: "runMode" in params ? params.runMode : undefined, }; } diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 12cfeb904..69fccb864 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -412,6 +412,7 @@ export class SessionService { adapter: resolvedAdapter, permissionMode: persistedMode, customInstructions: customInstructions || undefined, + runMode: "local", }); if (result) { @@ -586,6 +587,7 @@ export class SessionService { ? (reasoningLevel as EffortLevel) : undefined, model: preferredModel, + runMode: "local", }); const session = this.createBaseSession(taskRun.id, taskId, taskTitle); diff --git a/apps/mobile/app.json b/apps/mobile/app.json index bfc669988..08152767f 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -16,7 +16,7 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.posthog.mobile", + "bundleIdentifier": "com.aspicer.posthogmobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", @@ -30,7 +30,7 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.posthog.mobile", + "package": "com.aspicer.posthogmobile", "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS" diff --git a/apps/mobile/index.js b/apps/mobile/index.js new file mode 100644 index 000000000..80d3d998f --- /dev/null +++ b/apps/mobile/index.js @@ -0,0 +1 @@ +import "expo-router/entry"; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ee63d673a..233501cdb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,7 +1,7 @@ { "name": "@posthog/mobile", "version": "1.0.0", - "main": "expo-router/entry", + "main": "./index.js", "scripts": { "start": "expo start", "start:clear": "expo start --clear", diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index c0bb1f812..69870fd52 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -27,7 +27,6 @@ export function useTasks(filters?: { repository?: string }) { const queryFilters = { ...filters, - createdBy: currentUser?.id, }; const query = useQuery({ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..0e6371f6f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": {}, + "extends": "expo/tsconfig.base" +} From 6dc6a75c7eb16478af35ea546c658d2ecb74b46e Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 22:31:01 +0100 Subject: [PATCH 12/39] WIP - Add archivable tasks and make it so that we can improve resiliency for thinking phases --- apps/mobile/src/app/(tabs)/tasks.tsx | 33 +++- apps/mobile/src/app/task/[id].tsx | 7 +- .../features/chat/components/MarkdownText.tsx | 9 +- apps/mobile/src/features/tasks/api.ts | 4 +- .../tasks/components/SwipeableTaskItem.tsx | 143 ++++++++++++++++++ .../features/tasks/components/TaskList.tsx | 101 ++++++++++++- .../tasks/components/TaskSessionView.tsx | 7 + .../tasks/stores/archivedTasksStore.ts | 40 +++++ .../features/tasks/stores/taskSessionStore.ts | 15 +- 9 files changed, 340 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx create mode 100644 apps/mobile/src/features/tasks/stores/archivedTasksStore.ts diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index 0de5f874b..1e984eb17 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -1,18 +1,41 @@ import { Text } from "@components/text"; -import { useRouter } from "expo-router"; -import { Pressable, View } from "react-native"; +import { useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useRef } from "react"; +import { InteractionManager, Pressable, View } from "react-native"; import { TaskList } from "@/features/tasks"; export default function TasksScreen() { const router = useRouter(); + const readyRef = useRef(true); + + // Block navigation while a modal dismiss animation is in progress. + // When the screen loses focus (modal opens), readyRef is false. + // When focus returns (modal dismissed), we wait for all native + // animations to finish before allowing the next push. + useFocusEffect( + useCallback(() => { + const handle = InteractionManager.runAfterInteractions(() => { + readyRef.current = true; + }); + return () => { + readyRef.current = false; + handle.cancel(); + }; + }, []), + ); const handleCreateTask = () => { router.push("/task"); }; - const handleTaskPress = (taskId: string) => { - router.push(`/task/${taskId}`); - }; + const handleTaskPress = useCallback( + (taskId: string) => { + if (!readyRef.current) return; + readyRef.current = false; + router.push(`/task/${taskId}`); + }, + [router], + ); return ( diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index bbd9b969d..17c6902f3 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -47,26 +47,31 @@ export default function TaskDetailScreen() { useEffect(() => { if (!taskId) return; + let cancelled = false; setLoading(true); setError(null); getTask(taskId) .then((fetchedTask) => { + if (cancelled) return; setTask(fetchedTask); return connectToTask(fetchedTask); }) .then(() => { + if (cancelled) return; // Brief delay for FlatList to render its initial batch behind // the loading overlay before revealing. setTimeout(() => setLoading(false), 150); }) .catch((err) => { + if (cancelled) return; console.error("Failed to load task:", err); setError("Failed to load task"); setLoading(false); }); return () => { + cancelled = true; disconnectFromTask(taskId); }; }, [taskId, connectToTask, disconnectFromTask]); @@ -83,7 +88,7 @@ export default function TaskDetailScreen() { const handleOpenTask = useCallback( (newTaskId: string) => { - router.push(`/task/${newTaskId}`); + router.replace(`/task/${newTaskId}`); }, [router], ); diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index b0584fc8c..75c433b5f 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -38,7 +38,7 @@ function parseBlocks(text: string): Block[] { } // Heading - const headingMatch = line.match(/^(#{1,3})\s+(.+)$/); + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { blocks.push({ type: "heading", @@ -104,7 +104,7 @@ function parseBlocks(text: string): Block[] { i < lines.length && lines[i].trim() !== "" && !lines[i].startsWith("```") && - !lines[i].match(/^#{1,3}\s/) && + !lines[i].match(/^#{1,6}\s/) && !/^\s*[-*]\s/.test(lines[i]) && !/^\s*\d+[.)]\s/.test(lines[i]) && !(lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s-:|]+\|/.test(lines[i + 1])) @@ -233,9 +233,10 @@ export function MarkdownText({ content }: MarkdownTextProps) { {header && ( + {/* biome-ignore lint/suspicious/noArrayIndexKey: static markdown table cells never reorder */} {header.map((cell, ci) => ( 0 ? { borderLeftWidth: 1, borderLeftColor: "#3333" } : undefined} > @@ -246,11 +247,13 @@ export function MarkdownText({ content }: MarkdownTextProps) { ))} )} + {/* biome-ignore lint/suspicious/noArrayIndexKey: static markdown table rows never reorder */} {body.map((row, ri) => ( + {/* biome-ignore lint/suspicious/noArrayIndexKey: static markdown table cells never reorder */} {row.map((cell, ci) => ( { return withRetry( async () => { - const response = await fetch(logUrl); + const response = await fetch(logUrl, { + signal: AbortSignal.timeout(10_000), + }); if (!response.ok) { if (response.status === 404) { diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx new file mode 100644 index 000000000..256fdbcb8 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -0,0 +1,143 @@ +import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; +import { useEffect, useRef } from "react"; +import { + Animated, + Easing, + LayoutAnimation, + PanResponder, + Text, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { Task } from "../types"; +import { TaskItem } from "./TaskItem"; + +const SWIPE_THRESHOLD = 60; + +interface SwipeableTaskItemProps { + task: Task; + isArchived: boolean; + onPress: (task: Task) => void; + onArchive: (taskId: string) => void; + onUnarchive: (taskId: string) => void; + onSwipeStart?: () => void; + onSwipeEnd?: () => void; +} + +export function SwipeableTaskItem({ + task, + isArchived, + onPress, + onArchive, + onUnarchive, + onSwipeStart, + onSwipeEnd, +}: SwipeableTaskItemProps) { + const themeColors = useThemeColors(); + const translateX = useRef(new Animated.Value(0)).current; + const actionTriggeredRef = useRef(false); + + // Reset position when the item reappears (e.g. moved between sections) + useEffect(() => { + translateX.setValue(0); + actionTriggeredRef.current = false; + }, [isArchived, translateX]); + + const panResponder = useRef( + PanResponder.create({ + // Start tracking immediately on horizontal movement + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, gesture) => + Math.abs(gesture.dx) > 5 && + Math.abs(gesture.dx) > Math.abs(gesture.dy) && + gesture.dx < 0, + // Capture before children so FlatList doesn't steal + onMoveShouldSetPanResponderCapture: (_, gesture) => + Math.abs(gesture.dx) > 8 && + Math.abs(gesture.dx) > Math.abs(gesture.dy * 1.2) && + gesture.dx < 0, + // Never let go once we have the gesture + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => true, + onPanResponderGrant: () => { + actionTriggeredRef.current = false; + onSwipeStart?.(); + }, + onPanResponderMove: Animated.event( + [null, { dx: translateX }], + { + useNativeDriver: false, + listener: (_: unknown, gesture: { dx: number }) => { + // Clamp to left-only + if (gesture.dx > 0) translateX.setValue(0); + }, + }, + ), + onPanResponderRelease: (_, gesture) => { + onSwipeEnd?.(); + if (gesture.dx < -SWIPE_THRESHOLD && !actionTriggeredRef.current) { + actionTriggeredRef.current = true; + Animated.timing(translateX, { + toValue: -400, + duration: 150, + easing: Easing.in(Easing.ease), + useNativeDriver: true, + }).start(() => { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ); + if (isArchived) { + onUnarchive(task.id); + } else { + onArchive(task.id); + } + }); + } else { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + tension: 40, + friction: 8, + }).start(); + } + }, + onPanResponderTerminate: () => { + onSwipeEnd?.(); + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + }).start(); + }, + }), + ).current; + + const actionBg = isArchived ? themeColors.accent[9] : themeColors.gray[8]; + const ActionIcon = isArchived ? ArrowCounterClockwise : Archive; + const actionLabel = isArchived ? "Restore" : "Archive"; + + return ( + + {/* Action revealed behind the row */} + + + + {actionLabel} + + + + {/* Sliding task row */} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 88e91a10f..fbcd8c9dc 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,5 +1,7 @@ import { Text } from "@components/text"; import * as WebBrowser from "expo-web-browser"; +import { CaretRight } from "phosphor-react-native"; +import { useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -11,8 +13,9 @@ import { useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; import { useIntegrations } from "../hooks/useIntegrations"; import { useTasks } from "../hooks/useTasks"; +import { useArchivedTasksStore } from "../stores/archivedTasksStore"; import type { Task } from "../types"; -import { TaskItem } from "./TaskItem"; +import { SwipeableTaskItem } from "./SwipeableTaskItem"; interface TaskListProps { onTaskPress?: (taskId: string) => void; @@ -108,11 +111,18 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { ); } +type ListItem = + | { type: "task"; task: Task; isArchived: boolean } + | { type: "archived-header"; count: number; expanded: boolean }; + export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { const { tasks, isLoading, error, refetch } = useTasks(); const { hasGithubIntegration, refetch: refetchIntegrations } = useIntegrations(); const themeColors = useThemeColors(); + const { archivedTasks, archive, unarchive } = useArchivedTasksStore(); + const [archivedExpanded, setArchivedExpanded] = useState(false); + const [scrollEnabled, setScrollEnabled] = useState(true); const handleTaskPress = (task: Task) => { onTaskPress?.(task.id); @@ -122,6 +132,46 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { await Promise.all([refetch(), refetchIntegrations()]); }; + const listItems = useMemo((): ListItem[] => { + const active: Task[] = []; + const archived: Task[] = []; + + for (const task of tasks) { + if (task.id in archivedTasks) { + archived.push(task); + } else { + active.push(task); + } + } + + // Sort archived by FIFO (earliest archived first) + archived.sort( + (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), + ); + + const items: ListItem[] = active.map((task) => ({ + type: "task", + task, + isArchived: false, + })); + + if (archived.length > 0) { + items.push({ + type: "archived-header", + count: archived.length, + expanded: archivedExpanded, + }); + + if (archivedExpanded) { + for (const task of archived) { + items.push({ type: "task", task, isArchived: true }); + } + } + } + + return items; + }, [tasks, archivedTasks, archivedExpanded]); + if (error) { return ( @@ -160,14 +210,51 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { return ; } - // Has tasks - show the list (regardless of GitHub connection status) return ( item.id} - renderItem={({ item }) => ( - - )} + scrollEnabled={scrollEnabled} + data={listItems} + keyExtractor={(item) => + item.type === "archived-header" + ? "__archived_header__" + : `${item.task.id}-${item.isArchived ? "a" : "v"}` + } + renderItem={({ item }) => { + if (item.type === "archived-header") { + return ( + setArchivedExpanded(!item.expanded)} + className="flex-row items-center gap-2 border-gray-6 border-t bg-gray-2 px-3 py-2.5" + > + + + Archived + + {item.count} + + ); + } + + return ( + setScrollEnabled(false)} + onSwipeEnd={() => setScrollEnabled(true)} + /> + ); + }} refreshControl={ processNewEvents(processorRef.current, events), [events], diff --git a/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts new file mode 100644 index 000000000..8a2def9f4 --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts @@ -0,0 +1,40 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +interface ArchivedTasksState { + // taskId → timestamp (ms) for FIFO ordering + archivedTasks: Record; + archive: (taskId: string) => void; + unarchive: (taskId: string) => void; + isArchived: (taskId: string) => boolean; +} + +export const useArchivedTasksStore = create()( + persist( + (set, get) => ({ + archivedTasks: {}, + + archive: (taskId: string) => + set((state) => ({ + archivedTasks: { + ...state.archivedTasks, + [taskId]: Date.now(), + }, + })), + + unarchive: (taskId: string) => + set((state) => { + const { [taskId]: _, ...rest } = state.archivedTasks; + return { archivedTasks: rest }; + }), + + isArchived: (taskId: string) => taskId in get().archivedTasks, + }), + { + name: "archived-tasks", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ archivedTasks: state.archivedTasks }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 57b478134..155e53088 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -112,6 +112,9 @@ const connectAttempts = new Set(); // interval fires while the previous is still running, causing both to read // the same processedLineCount and produce duplicate events. const pollInFlight = new Set(); +// Timestamps for when each poll tick started — used to force-clear stuck ticks. +const pollInFlightSince = new Map(); +const POLL_IN_FLIGHT_TIMEOUT_MS = 30_000; // Tick counts per task run used to throttle backend task-run status polling. const pollTicks = new Map(); // How many S3 polling ticks between each backend task-run status check. @@ -410,9 +413,16 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Starting cloud S3 polling", { taskRunId }); const pollS3 = async () => { - // Skip if previous tick is still in flight - if (pollInFlight.has(taskRunId)) return; + // Skip if previous tick is still in flight — but force-clear if stuck + if (pollInFlight.has(taskRunId)) { + const startedAt = pollInFlightSince.get(taskRunId) ?? 0; + if (Date.now() - startedAt < POLL_IN_FLIGHT_TIMEOUT_MS) return; + logger.warn("Force-clearing stuck pollInFlight", { taskRunId }); + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); + } pollInFlight.add(taskRunId); + pollInFlightSince.set(taskRunId, Date.now()); try { const session = get().sessions[taskRunId]; @@ -641,6 +651,7 @@ export const useTaskSessionStore = create((set, get) => ({ logger.warn("Cloud polling error", { error: err }); } finally { pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); } }; From f7b5aae67b2885f3aebe5bcbeb781570e4469233 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Wed, 15 Apr 2026 23:33:45 +0100 Subject: [PATCH 13/39] WIP - Add retry for completed and failed cloud runs --- apps/mobile/src/app/task/[id].tsx | 101 +++++++++-- .../features/chat/components/MarkdownText.tsx | 102 +++++++---- .../tasks/components/TaskSessionView.tsx | 162 ++++++++++++------ 3 files changed, 260 insertions(+), 105 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 17c6902f3..e921ce778 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Composer } from "@/features/chat"; import { getTask, + runTaskInCloud, type Task, TaskSessionView, useTaskSessionStore, @@ -22,6 +23,7 @@ export default function TaskDetailScreen() { const [task, setTask] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(false); const { connectToTask, disconnectFromTask, sendPrompt, getSessionForTask } = useTaskSessionStore(); @@ -76,6 +78,31 @@ export default function TaskDetailScreen() { }; }, [taskId, connectToTask, disconnectFromTask]); + // Auto-reconnect if the session disappears while the screen is active + // (e.g., cloud sandbox expired and the session was cleaned up). + // Re-fetches the task to get a fresh S3 presigned URL. + useEffect(() => { + if (!taskId || !task || loading) return; + if (session) return; + if (retrying) return; + + let cancelled = false; + getTask(taskId) + .then((freshTask) => { + if (cancelled) return; + setTask(freshTask); + return connectToTask(freshTask); + }) + .catch((err) => { + if (cancelled) return; + console.error("Failed to reconnect to task:", err); + }); + + return () => { + cancelled = true; + }; + }, [taskId, task, loading, session, connectToTask, retrying]); + const handleSendPrompt = useCallback( (text: string) => { if (!taskId) return; @@ -86,6 +113,33 @@ export default function TaskDetailScreen() { [taskId, sendPrompt], ); + const handleRetry = useCallback(async () => { + if (!taskId || !task) return; + try { + setRetrying(true); + disconnectFromTask(taskId); + + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + }); + setTask(updatedTask); + await connectToTask(updatedTask); + // Don't clear retrying here — the effect below clears it + // once the session shows meaningful state (thinking or terminal). + } catch (err) { + console.error("Failed to retry task:", err); + setRetrying(false); + } + }, [taskId, task, disconnectFromTask, connectToTask]); + + // Clear retrying once the agent finishes a turn or the run terminates. + useEffect(() => { + if (!retrying || !session) return; + if (!session.isPromptPending || session.terminalStatus) { + setRetrying(false); + } + }, [retrying, session]); + const handleOpenTask = useCallback( (newTaskId: string) => { router.replace(`/task/${newTaskId}`); @@ -163,12 +217,25 @@ export default function TaskDetailScreen() { switching from loading spinner to rendered content. */} 0 + } + terminalStatus={retrying ? undefined : session?.terminalStatus} + lastError={retrying ? undefined : session?.lastError} + onRetry={ + !retrying && session?.terminalStatus ? handleRetry : undefined + } onOpenTask={handleOpenTask} onSendAnswer={handleSendPrompt} contentContainerStyle={{ - paddingTop: 80 + insets.bottom, + paddingTop: + session?.terminalStatus && !retrying ? 16 : 80 + insets.bottom, paddingBottom: 16, }} /> @@ -177,21 +244,25 @@ export default function TaskDetailScreen() { {loading && ( - Loading task... + + {task?.latest_run ? "Connecting..." : "Loading task..."} + )} - {/* Fixed input at bottom */} - - - + {/* Fixed input at bottom — hidden when run is terminal */} + {!session?.terminalStatus && ( + + + + )} ); diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index 75c433b5f..ad386e9d5 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -72,7 +72,11 @@ function parseBlocks(text: string): Block[] { } // Table: lines with pipes, second line is separator (|---|---|) - if (line.includes("|") && i + 1 < lines.length && /^\s*\|?[\s-:|]+\|/.test(lines[i + 1])) { + if ( + line.includes("|") && + i + 1 < lines.length && + /^\s*\|?[\s-:|]+\|/.test(lines[i + 1]) + ) { const rows: string[][] = []; while (i < lines.length && lines[i].includes("|")) { const row = lines[i] @@ -107,7 +111,11 @@ function parseBlocks(text: string): Block[] { !lines[i].match(/^#{1,6}\s/) && !/^\s*[-*]\s/.test(lines[i]) && !/^\s*\d+[.)]\s/.test(lines[i]) && - !(lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s-:|]+\|/.test(lines[i + 1])) + !( + lines[i].includes("|") && + i + 1 < lines.length && + /^\s*\|?[\s-:|]+\|/.test(lines[i + 1]) + ) ) { paraLines.push(lines[i]); i++; @@ -229,44 +237,68 @@ export function MarkdownText({ content }: MarkdownTextProps) { const header = rows[0]; const body = rows.slice(1); return ( - + {header && ( - {/* biome-ignore lint/suspicious/noArrayIndexKey: static markdown table cells never reorder */} - {header.map((cell, ci) => ( - 0 ? { borderLeftWidth: 1, borderLeftColor: "#3333" } : undefined} - > - - {renderInline(cell)} - - - ))} + {header.map((cell, col) => { + const colKey = `${key}-h${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} )} - {/* biome-ignore lint/suspicious/noArrayIndexKey: static markdown table rows never reorder */} - {body.map((row, ri) => ( - - {/* biome-ignore lint/suspicious/noArrayIndexKey: static markdown table cells never reorder */} - {row.map((cell, ci) => ( - 0 ? { borderLeftWidth: 1, borderLeftColor: "#3333" } : undefined} - > - - {renderInline(cell)} - - - ))} - - ))} + {body.map((row, ri) => { + const rowKey = `${key}-r${ri}`; + return ( + + {row.map((cell, col) => { + const cellKey = `${rowKey}-c${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} + + ); + })} ); diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index a2f7c3d1a..044519937 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -1,9 +1,4 @@ -import { - ArrowDown, - Brain, - CaretRight, - Robot, -} from "phosphor-react-native"; +import { ArrowDown, Brain, CaretRight, Robot } from "phosphor-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, @@ -28,6 +23,9 @@ interface TaskSessionViewProps { events: SessionEvent[]; isConnecting?: boolean; isThinking?: boolean; + terminalStatus?: "failed" | "completed"; + lastError?: string | null; + onRetry?: () => void; onOpenTask?: (taskId: string) => void; onSendAnswer?: (answer: string) => void; contentContainerStyle?: object; @@ -113,8 +111,7 @@ function parseSessionNotification( } case "tool_call": { const meta = update._meta?.claudeCode; - const isAgent = - meta?.toolName === "Agent" || meta?.toolName === "Task"; + const isAgent = meta?.toolName === "Agent" || meta?.toolName === "Task"; return { type: "tool", toolData: { @@ -387,7 +384,11 @@ function CollapsedThought({ content }: { content: string }) { function tryReassembleString(obj: Record): string | null { const numericKeys = Object.keys(obj).filter((k) => /^\d+$/.test(k)); if (numericKeys.length < 3) return null; - if (numericKeys.every((k) => typeof obj[k] === "string" && (obj[k] as string).length === 1)) { + if ( + numericKeys.every( + (k) => typeof obj[k] === "string" && (obj[k] as string).length === 1, + ) + ) { return numericKeys .sort((a, b) => Number(a) - Number(b)) .map((k) => obj[k]) @@ -410,7 +411,14 @@ function extractErrorText(result: unknown): string | null { if (reassembled) return reassembled; // Check simple string fields, recurse into nested objects - for (const key of ["error", "message", "stderr", "output", "text", "content"]) { + for (const key of [ + "error", + "message", + "stderr", + "output", + "text", + "content", + ]) { if (typeof obj[key] === "string") return obj[key] as string; if (obj[key] && typeof obj[key] === "object") { const nested = extractErrorText(obj[key]); @@ -439,11 +447,12 @@ function agentPromptSummary(args?: Record): string | null { : null; if (!prompt) return null; // Take the first meaningful line, truncated - const firstLine = prompt.split("\n").find((l) => l.trim())?.trim(); + const firstLine = prompt + .split("\n") + .find((l) => l.trim()) + ?.trim(); if (!firstLine) return null; - return firstLine.length > 120 - ? `${firstLine.slice(0, 120)}…` - : firstLine; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; } function AgentToolCard({ @@ -469,10 +478,7 @@ function AgentToolCard({ return ( {/* Header */} - setExpanded(!expanded)} - className="px-3 py-2" - > + setExpanded(!expanded)} className="px-3 py-2"> {isLoading ? ( @@ -551,20 +557,27 @@ function AgentToolCard({ ); } -const CONNECTING_MESSAGE: ParsedMessage = { - id: "__connecting__", - type: "connecting", - content: "", -}; +function formatElapsed(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} -const THINKING_MESSAGE: ParsedMessage = { - id: "__thinking__", - type: "thinking", - content: "", -}; +function useElapsedTimer() { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + setElapsed(0); + const interval = setInterval(() => { + setElapsed((e) => e + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + return elapsed; +} function ThinkingIndicator() { const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); useEffect(() => { const interval = setInterval(() => { @@ -574,17 +587,18 @@ function ThinkingIndicator() { }, []); return ( - + Thinking{".".repeat(dots)} + {formatElapsed(elapsed)} ); } function ConnectingIndicator() { - const themeColors = useThemeColors(); const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); useEffect(() => { const interval = setInterval(() => { @@ -594,10 +608,11 @@ function ConnectingIndicator() { }, []); return ( - + Connecting{".".repeat(dots)} + {formatElapsed(elapsed)} ); } @@ -606,6 +621,9 @@ export function TaskSessionView({ events, isConnecting, isThinking, + terminalStatus, + lastError, + onRetry, onOpenTask, onSendAnswer, contentContainerStyle, @@ -614,7 +632,10 @@ export function TaskSessionView({ const prevEventsRef = useRef(events); // Reset processor when events array shrinks or changes identity completely // (e.g., navigating between tasks while Expo Router reuses the component). - if (events.length === 0 || (events !== prevEventsRef.current && events[0] !== prevEventsRef.current[0])) { + if ( + events.length === 0 || + (events !== prevEventsRef.current && events[0] !== prevEventsRef.current[0]) + ) { processorRef.current = createProcessorState(); } prevEventsRef.current = events; @@ -624,22 +645,7 @@ export function TaskSessionView({ ); // Inverted FlatList renders data[0] at the visual bottom. // Reverse so newest messages are at index 0 = bottom. - const displayMessages = useMemo(() => { - const onlyUserMessages = messages.every((m) => m.type === "user"); - // Show "Connecting..." when pending and no agent activity yet - if (isConnecting && onlyUserMessages) { - return [...messages, CONNECTING_MESSAGE]; - } - // Show "Thinking..." when agent is working (after initial connection) - if (isThinking && !onlyUserMessages) { - return [...messages, THINKING_MESSAGE]; - } - return messages; - }, [messages, isConnecting, isThinking]); - const reversedMessages = useMemo( - () => [...displayMessages].reverse(), - [displayMessages], - ); + const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); const flatListRef = useRef(null); const buttonRef = useRef(null); @@ -676,10 +682,6 @@ export function TaskSessionView({ ); case "thought": return ; - case "connecting": - return ; - case "thinking": - return ; case "tool": if (!item.toolData) return null; if (isQuestionTool(item.toolData)) { @@ -691,9 +693,7 @@ export function TaskSessionView({ ); } if (item.toolData.isAgent) { - return ( - - ); + return ; } return ( + {/* Status indicators absolutely positioned above the Composer area. + Rendered outside FlatList to avoid inverted-list double-mount bugs. */} + {(terminalStatus || isConnecting || isThinking) && ( + + {terminalStatus ? ( + + + {terminalStatus === "failed" ? "Run failed" : "Run completed"} + + {lastError && ( + {lastError} + )} + {onRetry && ( + + + {terminalStatus === "failed" ? "Retry" : "Continue"} + + + )} + + ) : isConnecting ? ( + + ) : isThinking ? ( + + ) : null} + + )} Date: Thu, 16 Apr 2026 01:18:22 +0100 Subject: [PATCH 14/39] code --- .../services/agent/local-command-receiver.ts | 32 ++++- apps/code/src/main/services/agent/schemas.ts | 11 ++ .../src/main/services/agent/service.test.ts | 117 ++++++++++++++- apps/code/src/main/services/agent/service.ts | 133 ++++++++++++++---- apps/code/src/main/trpc/routers/agent.ts | 20 +++ .../features/sessions/service/service.test.ts | 3 + .../features/sessions/service/service.ts | 25 ++++ apps/mobile/app.json | 4 +- apps/mobile/src/app/task/[id].tsx | 21 ++- .../tasks/components/QuestionCard.tsx | 61 ++++++-- .../tasks/components/TaskSessionView.tsx | 61 ++++---- .../src/features/tasks/hooks/useTasks.ts | 16 +-- .../features/tasks/stores/taskSessionStore.ts | 103 +++++++++++++- packages/agent/src/acp-extensions.ts | 8 ++ .../claude/permissions/permission-handlers.ts | 20 +++ packages/agent/src/server/agent-server.ts | 32 +++++ tsconfig.json | 4 - 17 files changed, 574 insertions(+), 97 deletions(-) delete mode 100644 tsconfig.json diff --git a/apps/code/src/main/services/agent/local-command-receiver.ts b/apps/code/src/main/services/agent/local-command-receiver.ts index 432f00e2f..8a3b1d186 100644 --- a/apps/code/src/main/services/agent/local-command-receiver.ts +++ b/apps/code/src/main/services/agent/local-command-receiver.ts @@ -4,7 +4,12 @@ import { logger } from "../../utils/logger"; import type { AuthService } from "../auth/service"; const log = logger.scope("local-command-receiver"); -const RECONNECT_DELAY_MS = 2000; +const INITIAL_RECONNECT_DELAY_MS = 2000; +const MAX_RECONNECT_DELAY_MS = 30_000; +// After this many consecutive failures, assume Last-Event-ID is stale (event +// trimmed from the backend buffer) and fall back to a fresh connect with +// start=latest. Accepts that we may drop commands issued during the outage. +const STALE_EVENT_ID_THRESHOLD = 3; /** * JSON-RPC envelope carried inside an `incoming_command` SSE event. The @@ -90,8 +95,10 @@ export class LocalCommandReceiver { controller: AbortController, ): Promise { let lastEventId: string | undefined; + let consecutiveFailures = 0; while (!controller.signal.aborted) { + let streamOpened = false; try { const { accessToken } = await this.auth.getValidAccessToken(); const url = new URL( @@ -120,6 +127,8 @@ export class LocalCommandReceiver { ); } + streamOpened = true; + consecutiveFailures = 0; lastEventId = await this.readEventStream( response.body, params.onCommand, @@ -131,13 +140,32 @@ export class LocalCommandReceiver { }); } catch (err) { if (controller.signal.aborted) return; + if (!streamOpened) consecutiveFailures++; + if ( + consecutiveFailures >= STALE_EVENT_ID_THRESHOLD && + lastEventId !== undefined + ) { + log.warn( + "Dropping possibly-stale Last-Event-ID after repeated failures", + { + taskRunId: params.taskRunId, + consecutiveFailures, + }, + ); + lastEventId = undefined; + } log.warn("SSE disconnected, will reconnect", { taskRunId: params.taskRunId, + consecutiveFailures, error: err instanceof Error ? err.message : String(err), }); } if (controller.signal.aborted) return; - await this.sleep(RECONNECT_DELAY_MS, controller.signal); + const delay = Math.min( + MAX_RECONNECT_DELAY_MS, + INITIAL_RECONNECT_DELAY_MS * 2 ** Math.max(0, consecutiveFailures - 1), + ); + await this.sleep(delay, controller.signal); } } diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index ab3f62ee7..018af0556 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -181,6 +181,11 @@ export const recordActivityInput = z.object({ export const AgentServiceEvent = { SessionEvent: "session-event", PermissionRequest: "permission-request", + // Fires when a pending permission is resolved by anything other than the + // Electron UI (e.g. a mobile client calling permission_response). Renderer + // uses this to clear its own pendingPermissions copy in lockstep with the + // main-process map. + PermissionResolved: "permission-resolved", SessionsIdle: "sessions-idle", SessionIdleKilled: "session-idle-killed", AgentFileActivity: "agent-file-activity", @@ -209,9 +214,15 @@ export interface AgentFileActivityPayload { branchName: string | null; } +export interface PermissionResolvedPayload { + taskRunId: string; + toolCallId: string; +} + export interface AgentServiceEvents { [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; + [AgentServiceEvent.PermissionResolved]: PermissionResolvedPayload; [AgentServiceEvent.SessionsIdle]: undefined; [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index cad708898..8cc5789f7 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -194,11 +194,12 @@ const baseSessionParams = { describe("AgentService", () => { let service: AgentService; + let deps: ReturnType; beforeEach(() => { vi.clearAllMocks(); - const deps = createMockDependencies(); + deps = createMockDependencies(); service = new AgentService( deps.processTracking as never, deps.sleepService as never, @@ -445,4 +446,118 @@ describe("AgentService", () => { ); }); }); + + describe("local runMode", () => { + it("subscribes to local command receiver when runMode is local", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + expect(deps.localCommandReceiver.subscribe).toHaveBeenCalledTimes(1); + const call = deps.localCommandReceiver.subscribe.mock.calls[0][0]; + expect(call).toMatchObject({ + taskId: "task-1", + taskRunId: "run-1", + projectId: 1, + apiHost: "https://app.posthog.com", + }); + expect(typeof call.onCommand).toBe("function"); + }); + + it("does not subscribe when runMode is cloud", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "cloud", + }); + + expect(deps.localCommandReceiver.subscribe).not.toHaveBeenCalled(); + }); + + it("delivers user_message commands to the session via prompt", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ + jsonrpc: "2.0", + method: "user_message", + params: { content: "hello from mobile" }, + }); + + expect(promptSpy).toHaveBeenCalledWith("run-1", [ + { type: "text", text: "hello from mobile" }, + ]); + }); + + it("ignores non-user_message commands", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ + jsonrpc: "2.0", + method: "something_else", + params: { content: "ignored" }, + }); + + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it("ignores commands with missing or non-string content", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ jsonrpc: "2.0", method: "user_message", params: {} }); + await onCommand({ + jsonrpc: "2.0", + method: "user_message", + params: { content: "" }, + }); + + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it("unsubscribes on session cleanup", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + await ( + service as unknown as { + cleanupSession: (id: string) => Promise; + } + ).cleanupSession("run-1"); + + expect(deps.localCommandReceiver.unsubscribe).toHaveBeenCalledWith( + "run-1", + ); + }); + }); }); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 3f24acfe4..e15e68a57 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -395,6 +395,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -423,6 +424,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -817,37 +819,12 @@ When creating pull requests, add the following footer at the end of the PR descr this.recordActivity(taskRunId); if (config.runMode === "local") { - // Subscribe to the task-run SSE stream so mobile-originated - // /command/ calls (published to Redis by the backend) are - // delivered into this local session as prompts. - this.localCommandReceiver.subscribe({ + this.ensureLocalCommandSubscription( taskId, taskRunId, - projectId: credentials.projectId, - apiHost: credentials.apiHost, - onCommand: async (payload) => { - if (payload.method !== "user_message") { - log.debug("Ignoring non-user_message local command", { - method: payload.method, - taskRunId, - }); - return; - } - const content = payload.params?.content; - if (typeof content !== "string" || content.length === 0) { - log.warn("Local command missing content", { taskRunId }); - return; - } - try { - await this.prompt(taskRunId, [{ type: "text", text: content }]); - } catch (err) { - log.error("Failed to deliver local command to session", { - taskRunId, - error: err instanceof Error ? err.message : String(err), - }); - } - }, - }); + credentials.projectId, + credentials.apiHost, + ); } if (isRetry) { @@ -1189,6 +1166,104 @@ For git operations while detached: session.inFlightMcpToolCalls.clear(); } + /** + * Idempotently subscribe the LocalCommandReceiver to a task-run's SSE + * stream so mobile-originated /command/ calls reach this session. Called + * both on fresh session creation and when an existing session is reused + * — the receiver itself short-circuits duplicate subscribes, so multiple + * calls are safe. Without this, a session that existed before this code + * path shipped (or that had its subscription torn down by an earlier + * cleanup) would silently drop mobile commands. + */ + private ensureLocalCommandSubscription( + taskId: string, + taskRunId: string, + projectId: number, + apiHost: string, + ): void { + this.localCommandReceiver.subscribe({ + taskId, + taskRunId, + projectId, + apiHost, + onCommand: async (payload) => { + log.debug("Local command received", { + taskRunId, + method: payload.method, + }); + + // Mobile (or any external client) answering an outstanding + // requestPermission call. Route it directly to the pending + // promise rather than treating it as a new prompt — otherwise + // the agent stays blocked inside the current turn and the + // answer starts a second turn that can never run. + if (payload.method === "permission_response") { + const params = payload.params ?? {}; + const toolCallId = + typeof params.toolCallId === "string" + ? params.toolCallId + : undefined; + const optionId = + typeof params.optionId === "string" ? params.optionId : undefined; + const customInput = + typeof params.customInput === "string" + ? params.customInput + : undefined; + const rawAnswers = params.answers; + const answers = + rawAnswers && typeof rawAnswers === "object" + ? (rawAnswers as Record) + : undefined; + if (!toolCallId || !optionId) { + log.warn("Invalid permission_response from external client", { + taskRunId, + hasToolCallId: !!toolCallId, + hasOptionId: !!optionId, + }); + return; + } + try { + this.respondToPermission( + taskRunId, + toolCallId, + optionId, + customInput, + answers, + ); + } catch (err) { + log.error("Failed to apply external permission_response", { + taskRunId, + toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + } + return; + } + + if (payload.method !== "user_message") { + log.debug("Ignoring non-user_message local command", { + method: payload.method, + taskRunId, + }); + return; + } + const content = payload.params?.content; + if (typeof content !== "string" || content.length === 0) { + log.warn("Local command missing content", { taskRunId }); + return; + } + try { + await this.prompt(taskRunId, [{ type: "text", text: content }]); + } catch (err) { + log.error("Failed to deliver local command to session", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + }); + } + private async cleanupSession(taskRunId: string): Promise { // Abort any outstanding SSE subscription for this run first — per // async-cleanup-ordering guidance, we release external resources diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 98c20a8ce..835845332 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -105,6 +105,26 @@ export const agentRouter = router({ } }), + // Permission resolved subscription - yields when a pending permission gets + // answered through a path other than the local UI (e.g. a mobile client). + // The renderer uses this to clear its mirror of pendingPermissions. + onPermissionResolved: publicProcedure + .input(subscribeSessionInput) + .subscription(async function* (opts) { + const service = getService(); + const targetTaskRunId = opts.input.taskRunId; + const iterable = service.toIterable( + AgentServiceEvent.PermissionResolved, + { signal: opts.signal }, + ); + + for await (const event of iterable) { + if (event.taskRunId === targetTaskRunId) { + yield event; + } + } + }), + // Respond to a permission request from the UI respondToPermission: publicProcedure .input(respondToPermissionInput) diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index 7305679de..dce667748 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -16,6 +16,9 @@ const mockTrpcAgent = vi.hoisted(() => ({ cancelPermission: { mutate: vi.fn() }, onSessionEvent: { subscribe: vi.fn() }, onPermissionRequest: { subscribe: vi.fn() }, + onPermissionResolved: { + subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })), + }, onSessionIdleKilled: { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) }, resetAll: { mutate: vi.fn().mockResolvedValue(undefined) }, })); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 69fccb864..c1c87435a 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -118,6 +118,7 @@ export class SessionService { { event: { unsubscribe: () => void }; permission?: { unsubscribe: () => void }; + permissionResolved?: { unsubscribe: () => void }; } >(); /** Active cloud task watchers, keyed by taskId */ @@ -697,9 +698,32 @@ export class SessionService { }, ); + // Clears the local pendingPermissions mirror when a permission is + // resolved outside the Electron UI (e.g. a mobile client answers + // the question). Without this, the desktop card would stay visible + // indefinitely even though the agent has already moved on. + const permissionResolvedSubscription = + trpcClient.agent.onPermissionResolved.subscribe( + { taskRunId }, + { + onData: (payload) => { + const session = sessionStoreSetters.getSessions()[taskRunId]; + if (!session) return; + this.resolvePermission(session, payload.toolCallId); + }, + onError: (err) => { + log.error("Permission-resolved subscription error", { + taskRunId, + error: err, + }); + }, + }, + ); + this.subscriptions.set(taskRunId, { event: eventSubscription, permission: permissionSubscription, + permissionResolved: permissionResolvedSubscription, }); } @@ -707,6 +731,7 @@ export class SessionService { const subscription = this.subscriptions.get(taskRunId); subscription?.event.unsubscribe(); subscription?.permission?.unsubscribe(); + subscription?.permissionResolved?.unsubscribe(); this.subscriptions.delete(taskRunId); } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 08152767f..bfc669988 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -16,7 +16,7 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.aspicer.posthogmobile", + "bundleIdentifier": "com.posthog.mobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", @@ -30,7 +30,7 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.aspicer.posthogmobile", + "package": "com.posthog.mobile", "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS" diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index bbd9b969d..25260a4f8 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -23,8 +23,13 @@ export default function TaskDetailScreen() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { connectToTask, disconnectFromTask, sendPrompt, getSessionForTask } = - useTaskSessionStore(); + const { + connectToTask, + disconnectFromTask, + sendPrompt, + sendPermissionResponse, + getSessionForTask, + } = useTaskSessionStore(); const session = taskId ? getSessionForTask(taskId) : undefined; @@ -81,6 +86,16 @@ export default function TaskDetailScreen() { [taskId, sendPrompt], ); + const handleSendPermissionResponse = useCallback( + (args: Parameters[1]) => { + if (!taskId) return; + sendPermissionResponse(taskId, args).catch((err) => { + console.error("Failed to send permission response:", err); + }); + }, + [taskId, sendPermissionResponse], + ); + const handleOpenTask = useCallback( (newTaskId: string) => { router.push(`/task/${newTaskId}`); @@ -161,7 +176,7 @@ export default function TaskDetailScreen() { isConnecting={session?.isPromptPending ?? false} isThinking={session?.isPromptPending ?? false} onOpenTask={handleOpenTask} - onSendAnswer={handleSendPrompt} + onSendPermissionResponse={handleSendPermissionResponse} contentContainerStyle={{ paddingTop: 80 + insets.bottom, paddingBottom: 16, diff --git a/apps/mobile/src/features/tasks/components/QuestionCard.tsx b/apps/mobile/src/features/tasks/components/QuestionCard.tsx index af70b5f4c..6cca5c807 100644 --- a/apps/mobile/src/features/tasks/components/QuestionCard.tsx +++ b/apps/mobile/src/features/tasks/components/QuestionCard.tsx @@ -35,9 +35,17 @@ interface ToolData { result?: unknown; } +interface PermissionResponseArgs { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; +} + interface QuestionCardProps { toolData: ToolData; - onSendAnswer?: (answer: string) => void; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; } function extractQuestions(args?: Record): QuestionItem[] { @@ -70,7 +78,10 @@ function extractAnswer(result: unknown): string | null { return null; } -export function QuestionCard({ toolData, onSendAnswer }: QuestionCardProps) { +export function QuestionCard({ + toolData, + onSendPermissionResponse, +}: QuestionCardProps) { const themeColors = useThemeColors(); const questions = extractQuestions(toolData.args); const isCompleted = @@ -115,16 +126,22 @@ export function QuestionCard({ toolData, onSendAnswer }: QuestionCardProps) { } return ( - + ); } function InteractiveQuestion({ questions, - onSendAnswer, + toolCallId, + onSendPermissionResponse, }: { questions: QuestionItem[]; - onSendAnswer?: (answer: string) => void; + toolCallId: string; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; }) { const themeColors = useThemeColors(); const [currentIndex, setCurrentIndex] = useState(0); @@ -195,18 +212,38 @@ function InteractiveQuestion({ for (const label of selected) { parts.push(label); } - if (otherText.trim()) { - parts.push(otherText.trim()); + const trimmedOther = otherText.trim(); + if (trimmedOther) { + parts.push(trimmedOther); } const answer = parts.join(", "); - if (isLastQuestion) { - if (answer && onSendAnswer) { - onSendAnswer(answer); - } - } else { + if (!isLastQuestion) { setCurrentIndex(currentIndex + 1); + return; } + + if (!answer || !onSendPermissionResponse) return; + + // Derive the ACP optionId the agent is expecting. Options are built + // server-side (buildQuestionOptions in packages/agent) as + // `${OPTION_PREFIX}${idx}` where OPTION_PREFIX is "option_". If the + // user only typed into "Other", fall back to option_0 — the answers + // map carries the actual content for the agent. + const firstSelectedLabel = parts[0]; + const selectedIdx = question.options.findIndex( + (o) => o.label === firstSelectedLabel, + ); + const optionIdx = selectedIdx >= 0 ? selectedIdx : 0; + const optionId = `option_${optionIdx}`; + + onSendPermissionResponse({ + toolCallId, + optionId, + answers: { [question.question]: answer }, + customInput: trimmedOther || undefined, + displayText: answer, + }); }; return ( diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index e81257c81..b09b3c1e1 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -1,9 +1,4 @@ -import { - ArrowDown, - Brain, - CaretRight, - Robot, -} from "phosphor-react-native"; +import { ArrowDown, Brain, CaretRight, Robot } from "phosphor-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, @@ -24,12 +19,20 @@ import type { PlanEntry, SessionEvent, SessionNotification } from "../types"; import { PlanStatusBar } from "./PlanStatusBar"; import { QuestionCard } from "./QuestionCard"; +interface PermissionResponseArgs { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; +} + interface TaskSessionViewProps { events: SessionEvent[]; isConnecting?: boolean; isThinking?: boolean; onOpenTask?: (taskId: string) => void; - onSendAnswer?: (answer: string) => void; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; contentContainerStyle?: object; } @@ -113,8 +116,7 @@ function parseSessionNotification( } case "tool_call": { const meta = update._meta?.claudeCode; - const isAgent = - meta?.toolName === "Agent" || meta?.toolName === "Task"; + const isAgent = meta?.toolName === "Agent" || meta?.toolName === "Task"; return { type: "tool", toolData: { @@ -387,7 +389,11 @@ function CollapsedThought({ content }: { content: string }) { function tryReassembleString(obj: Record): string | null { const numericKeys = Object.keys(obj).filter((k) => /^\d+$/.test(k)); if (numericKeys.length < 3) return null; - if (numericKeys.every((k) => typeof obj[k] === "string" && (obj[k] as string).length === 1)) { + if ( + numericKeys.every( + (k) => typeof obj[k] === "string" && (obj[k] as string).length === 1, + ) + ) { return numericKeys .sort((a, b) => Number(a) - Number(b)) .map((k) => obj[k]) @@ -410,7 +416,14 @@ function extractErrorText(result: unknown): string | null { if (reassembled) return reassembled; // Check simple string fields, recurse into nested objects - for (const key of ["error", "message", "stderr", "output", "text", "content"]) { + for (const key of [ + "error", + "message", + "stderr", + "output", + "text", + "content", + ]) { if (typeof obj[key] === "string") return obj[key] as string; if (obj[key] && typeof obj[key] === "object") { const nested = extractErrorText(obj[key]); @@ -439,11 +452,12 @@ function agentPromptSummary(args?: Record): string | null { : null; if (!prompt) return null; // Take the first meaningful line, truncated - const firstLine = prompt.split("\n").find((l) => l.trim())?.trim(); + const firstLine = prompt + .split("\n") + .find((l) => l.trim()) + ?.trim(); if (!firstLine) return null; - return firstLine.length > 120 - ? `${firstLine.slice(0, 120)}…` - : firstLine; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; } function AgentToolCard({ @@ -469,10 +483,7 @@ function AgentToolCard({ return ( {/* Header */} - setExpanded(!expanded)} - className="px-3 py-2" - > + setExpanded(!expanded)} className="px-3 py-2"> {isLoading ? ( @@ -583,7 +594,7 @@ function ThinkingIndicator() { } function ConnectingIndicator() { - const themeColors = useThemeColors(); + const _themeColors = useThemeColors(); const [dots, setDots] = useState(1); useEffect(() => { @@ -607,7 +618,7 @@ export function TaskSessionView({ isConnecting, isThinking, onOpenTask, - onSendAnswer, + onSendPermissionResponse, contentContainerStyle, }: TaskSessionViewProps) { const processorRef = useRef(createProcessorState()); @@ -679,14 +690,12 @@ export function TaskSessionView({ return ( ); } if (item.toolData.isAgent) { - return ( - - ); + return ; } return ( [...taskKeys.all, "list"] as const, - list: (filters?: { repository?: string; createdBy?: number }) => + list: (filters?: { repository?: string }) => [...taskKeys.lists(), filters] as const, details: () => [...taskKeys.all, "detail"] as const, detail: (id: string) => [...taskKeys.details(), id] as const, @@ -63,23 +63,15 @@ export function useTask(taskId: string) { export function useCreateTask() { const queryClient = useQueryClient(); - const { data: currentUser } = useUserQuery(); - const invalidateTasks = (newTask?: Task) => { - if (newTask && currentUser?.id) { - // Update the correct cache entry with the user's filter - const queryKey = taskKeys.list({ createdBy: currentUser.id }); - queryClient.setQueryData(queryKey, (old) => - old ? [newTask, ...old] : [newTask], - ); - } + const invalidateTasks = () => { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); }; const mutation = useMutation({ mutationFn: (options: CreateTaskOptions) => createTask(options), - onSuccess: (newTask) => { - invalidateTasks(newTask); + onSuccess: () => { + invalidateTasks(); }, onError: (error) => { console.error("Failed to create task:", error.message); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 57b478134..f899ca05b 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -93,6 +93,16 @@ interface TaskSessionStore { connectToTask: (task: Task) => Promise; disconnectFromTask: (taskId: string) => void; sendPrompt: (taskId: string, prompt: string) => Promise; + sendPermissionResponse: ( + taskId: string, + args: { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; + }, + ) => Promise; cancelPrompt: (taskId: string) => Promise; getSessionForTask: (taskId: string) => TaskSession | undefined; @@ -124,7 +134,7 @@ export const useTaskSessionStore = create((set, get) => ({ const taskId = task.id; const latestRunId = task.latest_run?.id; const latestRunLogUrl = task.latest_run?.log_url; - const taskDescription = task.description; + const _taskDescription = task.description; if (connectAttempts.has(taskId)) { logger.debug("Connection already in progress", { taskId }); @@ -357,6 +367,84 @@ export const useTaskSessionStore = create((set, get) => ({ } }, + // Resolve an outstanding requestPermission on the desktop/agent side + // (e.g. AskUserQuestion). Unlike sendPrompt, this never queues — a + // permission reply only makes sense while the agent is paused inside + // requestPermission, and it completes an existing turn rather than + // starting a new one. + sendPermissionResponse: async (taskId, args) => { + const session = get().getSessionForTask(taskId); + if (!session) { + throw new Error("No active session for task"); + } + + const ts = Date.now(); + const userEvent: SessionEvent = { + type: "session_update", + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: args.displayText }, + }, + }, + }; + + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + awaitingPing: true, + }, + }, + }; + }); + + try { + await sendCloudCommand(taskId, session.taskRunId, "permission_response", { + toolCallId: args.toolCallId, + optionId: args.optionId, + ...(args.answers ? { answers: args.answers } : {}), + ...(args.customInput ? { customInput: args.customInput } : {}), + }); + logger.debug("Sent permission_response", { + taskId, + runId: session.taskRunId, + toolCallId: args.toolCallId, + }); + } catch (err) { + logger.error("Failed to send permission_response", err); + // Roll back the optimistic state so the UI reflects reality. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw err; + } + }, + cancelPrompt: async (taskId: string) => { const session = get().getSessionForTask(taskId); if (!session) return false; @@ -460,10 +548,7 @@ export const useTaskSessionStore = create((set, get) => ({ }, }; }); - if ( - shouldPing && - usePreferencesStore.getState().pingsEnabled - ) { + if (shouldPing && usePreferencesStore.getState().pingsEnabled) { playMeepSound().catch(() => {}); } } @@ -550,7 +635,13 @@ export const useTaskSessionStore = create((set, get) => ({ entry.type === "notification" && (entry.notification?.method === "_posthog/turn_complete" || entry.notification?.method === "_posthog/task_complete" || - entry.notification?.method === "_posthog/error") + entry.notification?.method === "_posthog/error" || + // Agent explicitly blocked on a user reply (e.g. a question + // tool invoked via requestPermission). Treat this as a + // turn boundary so the input UI unblocks — otherwise the + // user's answer would be stuck in the "queue while busy" + // path in sendPrompt. + entry.notification?.method === "_posthog/awaiting_user_input") ) { receivedAgentMessage = true; } diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index 62a2a1083..315b186e9 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -25,6 +25,14 @@ export const POSTHOG_NOTIFICATIONS = { /** Agent finished processing a turn (prompt returned, waiting for next input) */ TURN_COMPLETE: "_posthog/turn_complete", + /** + * Agent has stopped mid-turn and is blocked on a user reply (e.g. a + * question tool invoked via requestPermission). Emitted by permission + * handlers before they block; clients use it to unblock their input UI + * so the user's answer is sent directly instead of being queued. + */ + AWAITING_USER_INPUT: "_posthog/awaiting_user_input", + /** Error occurred during task execution */ ERROR: "_posthog/error", diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 41aa80ab1..f48e4172a 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -3,6 +3,7 @@ import type { RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; +import { POSTHOG_NOTIFICATIONS } from "../../../acp-extensions"; import { text } from "../../../utils/acp-content"; import type { Logger } from "../../../utils/logger"; import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp"; @@ -276,6 +277,25 @@ async function handleAskUserQuestionTool( input: toolInput, }); + // Tell any attached clients (including log readers like the mobile app) + // that the agent has stopped and is blocked on a user reply. Without this + // signal, clients reading the log-only stream have no reliable way to + // distinguish "tool running" from "tool waiting for a human" — and treat + // replies as queued-while-busy instead of sending them through. + try { + await client.extNotification(POSTHOG_NOTIFICATIONS.AWAITING_USER_INPUT, { + sessionId, + toolCallId: toolUseID, + }); + } catch (err) { + context.logger.warn( + "[AskUserQuestion] Failed to emit awaiting_user_input", + { + error: err, + }, + ); + } + const response = await client.requestPermission({ options, sessionId, diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index ebe4554a4..ad2775575 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -561,6 +561,38 @@ export class AgentServer { }; } + case "permission_response": { + // Cloud questions are not a blocking permission wait (the cloud + // permission handler returns "cancelled" with a wait hint so + // Claude ends its turn, then resumes on the next user_message). + // So a mobile permission_response for a cloud run is effectively + // a follow-up prompt — forward it through the user_message path + // using the user's answer as prompt text. + const customInput = + typeof params.customInput === "string" ? params.customInput : ""; + const rawAnswers = params.answers; + const answerValues = + rawAnswers && + typeof rawAnswers === "object" && + !Array.isArray(rawAnswers) + ? Object.values(rawAnswers as Record).filter( + (v): v is string => typeof v === "string", + ) + : []; + const answerText = customInput || answerValues.join(", "); + + if (!answerText) { + this.logger.warn("permission_response missing answer content", { + toolCallId: params.toolCallId, + }); + return { stopReason: "cancelled" }; + } + + return await this.executeCommand("user_message", { + content: answerText, + }); + } + case POSTHOG_NOTIFICATIONS.CANCEL: case "cancel": { this.logger.info("Cancel requested", { diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 0e6371f6f..000000000 --- a/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "compilerOptions": {}, - "extends": "expo/tsconfig.base" -} From be180d444284bbabdb061f71152562840a43296f Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 11:13:24 +0100 Subject: [PATCH 15/39] WIP - Added improvmeents for the thinking and connecting messages --- apps/mobile/src/app/task/[id].tsx | 26 ++++++---- .../tasks/components/TaskSessionView.tsx | 38 +++++++++----- .../src/features/tasks/hooks/useTasks.ts | 1 + .../features/tasks/stores/taskSessionStore.ts | 49 +++++++++++++++++-- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 5372a88cb..c819e943d 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -192,6 +192,21 @@ export default function TaskDetailScreen() { const environment = task?.latest_run?.environment; + const visibleAgentTypes = [ + "agent_message_chunk", + "agent_message", + "agent_thought_chunk", + "tool_call", + ]; + const hasAnyAgentOutput = + session?.events.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as Record)?.update; + return visibleAgentTypes.includes( + (su as Record)?.sessionUpdate as string, + ); + }) ?? false; + return ( <> 0 - } + isConnecting={retrying || (!!session?.awaitingAgentOutput && !hasAnyAgentOutput)} + isThinking={!!session?.awaitingAgentOutput && hasAnyAgentOutput} terminalStatus={retrying ? undefined : session?.terminalStatus} lastError={retrying ? undefined : session?.lastError} onRetry={ diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index af5c226fc..432af4b20 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -1,4 +1,4 @@ -import { ArrowDown, Brain, CaretRight, Robot } from "phosphor-react-native"; +import { ArrowDown, Brain, CaretRight, CloudArrowDown, Robot } from "phosphor-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, @@ -584,6 +584,7 @@ function useElapsedTimer() { } function ThinkingIndicator() { + const themeColors = useThemeColors(); const [dots, setDots] = useState(1); const elapsed = useElapsedTimer(); @@ -595,16 +596,24 @@ function ThinkingIndicator() { }, []); return ( - - - Thinking{".".repeat(dots)} - - {formatElapsed(elapsed)} + + + + + + Thinking{".".repeat(dots)} + + + + {formatElapsed(elapsed)} + + ); } function ConnectingIndicator() { + const themeColors = useThemeColors(); const [dots, setDots] = useState(1); const elapsed = useElapsedTimer(); @@ -616,11 +625,18 @@ function ConnectingIndicator() { }, []); return ( - - - Connecting{".".repeat(dots)} - - {formatElapsed(elapsed)} + + + + + + Connecting{".".repeat(dots)} + + + + {formatElapsed(elapsed)} + + ); } diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 63727f1d8..e807e7161 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -27,6 +27,7 @@ export function useTasks(filters?: { repository?: string }) { const queryFilters = { ...filters, + createdBy: currentUser?.id, }; const query = useQuery({ diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 994ec81d0..96609ec9d 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -82,6 +82,9 @@ export interface TaskSession { // we should play a sound when control returns. False when reconnecting // to an already-running task to avoid spurious pings. awaitingPing?: boolean; + // True after a user prompt is sent, cleared when the first piece of + // agent output (tool call, message, etc.) arrives from polling. + awaitingAgentOutput?: boolean; // Messages queued while the agent is working. Auto-sent when control // returns (isPromptPending flips to false). messageQueue?: string[]; @@ -176,6 +179,7 @@ export const useTaskSessionStore = create((set, get) => ({ logUrl: newLogUrl, processedLineCount: 0, awaitingPing: true, + awaitingAgentOutput: true, }, }, })); @@ -218,6 +222,9 @@ export const useTaskSessionStore = create((set, get) => ({ ? (task.latest_run?.error_message ?? null) : null; + const agentIsIdle = inferAgentIsIdle(rawEntries, notifications); + const isPromptPending = isTerminal ? false : !agentIsIdle; + set((state) => ({ sessions: { ...state.sessions, @@ -226,13 +233,27 @@ export const useTaskSessionStore = create((set, get) => ({ taskId, events: historicalEvents, status: "connected", - isPromptPending: isTerminal - ? false - : !inferAgentIsIdle(rawEntries, notifications), + isPromptPending, logUrl: latestRunLogUrl, processedLineCount: rawEntries.length, terminalStatus, lastError, + // Show "Connecting/Thinking" for active non-terminal runs + // that haven't produced visible agent output yet. + awaitingAgentOutput: + isPromptPending && + !historicalEvents.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as SessionNotification)?.update + ?.sessionUpdate; + return ( + su === "agent_message_chunk" || + su === "agent_message" || + su === "agent_thought_chunk" || + su === "tool_call" || + su === "tool_call_update" + ); + }), }, }, })); @@ -317,6 +338,7 @@ export const useTaskSessionStore = create((set, get) => ({ localUserEchoes: nextLocalEchoes, isPromptPending: true, awaitingPing: true, + awaitingAgentOutput: true, }, }, }; @@ -407,6 +429,7 @@ export const useTaskSessionStore = create((set, get) => ({ localUserEchoes: nextLocalEchoes, isPromptPending: true, awaitingPing: true, + awaitingAgentOutput: true, }, }, }; @@ -710,6 +733,24 @@ export const useTaskSessionStore = create((set, get) => ({ } if (receivedAgentMessage) nextAwaitingPing = false; + // Clear awaitingAgentOutput once a visibly-rendered event arrives + // (agent message, thought, tool call) — not just any non-user event. + const visibleSessionUpdates = new Set([ + "agent_message_chunk", + "agent_message", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + ]); + const hasVisibleAgentOutput = batchedEvents.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as SessionNotification)?.update + ?.sessionUpdate; + return su !== undefined && visibleSessionUpdates.has(su); + }); + const nextAwaitingAgentOutput = + current.awaitingAgentOutput && !hasVisibleAgentOutput; + return { sessions: { ...state.sessions, @@ -727,6 +768,7 @@ export const useTaskSessionStore = create((set, get) => ({ : undefined, isPromptPending: nextIsPromptPending, awaitingPing: nextAwaitingPing, + awaitingAgentOutput: nextAwaitingAgentOutput, }, }, }; @@ -805,6 +847,7 @@ export const useTaskSessionStore = create((set, get) => ({ processedLineCount: 0, processedHashes: new Set(), awaitingPing: true, + awaitingAgentOutput: true, }, }, }; From 00cfbc2a016b75a789ceddf26fccf7daf07ded4d Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 12:34:41 +0100 Subject: [PATCH 16/39] WIP - Added network detector / Better error handling in error cases and long press to copy messages --- apps/mobile/app.json | 4 +- apps/mobile/package.json | 3 ++ apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/task/[id].tsx | 18 +++++++- apps/mobile/src/components/OfflineBanner.tsx | 23 ++++++++++ .../features/chat/components/AgentMessage.tsx | 24 +++++++--- .../tasks/components/TaskSessionView.tsx | 8 +++- apps/mobile/src/hooks/useNetworkStatus.ts | 15 +++++++ pnpm-lock.yaml | 44 ++++++++++++++++++- 9 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/components/OfflineBanner.tsx create mode 100644 apps/mobile/src/hooks/useNetworkStatus.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index bfc669988..beb71e880 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -1,6 +1,6 @@ { "expo": { - "name": "PostHog AI", + "name": "PostHog Code", "slug": "posthog", "version": "1.0.0", "orientation": "portrait", @@ -16,7 +16,7 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.posthog.mobile", + "bundleIdentifier": "com.posthog.code", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 233501cdb..568da076b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -24,12 +24,14 @@ "dependencies": { "@expo/ui": "0.2.0-beta.9", "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.90.12", "date-fns": "^4.1.0", "expo": "~54.0.27", "expo-application": "~7.0.8", "expo-auth-session": "^7.0.10", "expo-av": "~16.0.8", + "expo-clipboard": "^55.0.13", "expo-constants": "~18.0.11", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -37,6 +39,7 @@ "expo-file-system": "~19.0.21", "expo-font": "^14.0.10", "expo-glass-effect": "~0.1.8", + "expo-haptics": "^55.0.14", "expo-linear-gradient": "^15.0.8", "expo-linking": "~8.0.10", "expo-localization": "~17.0.8", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index eff743833..344e9b162 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -9,6 +9,7 @@ import { useEffect } from "react"; import { ActivityIndicator, View } from "react-native"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { OfflineBanner } from "@/components/OfflineBanner"; import { useAuthStore } from "@/features/auth"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { @@ -115,6 +116,7 @@ export default function RootLayout() { + diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index c819e943d..a1ba58115 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Pressable, View } from "react-native"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -113,6 +113,10 @@ export default function TaskDetailScreen() { if (!taskId) return; sendPrompt(taskId, text).catch((err) => { console.error("Failed to send prompt:", err); + Alert.alert( + "Failed to send", + "Your message could not be delivered. Please try again.", + ); }); }, [taskId, sendPrompt], @@ -134,6 +138,10 @@ export default function TaskDetailScreen() { } catch (err) { console.error("Failed to retry task:", err); setRetrying(false); + Alert.alert( + "Retry failed", + "Could not restart the task. Please try again.", + ); } }, [taskId, task, disconnectFromTask, connectToTask]); @@ -150,6 +158,10 @@ export default function TaskDetailScreen() { if (!taskId) return; sendPermissionResponse(taskId, args).catch((err) => { console.error("Failed to send permission response:", err); + Alert.alert( + "Failed to respond", + "Your permission response could not be sent. Please try again.", + ); }); }, [taskId, sendPermissionResponse], @@ -247,7 +259,9 @@ export default function TaskDetailScreen() { switching from loading spinner to rendered content. */} + + + No internet connection + + + ); +} diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx index 8a5e86042..11b0469c3 100644 --- a/apps/mobile/src/features/chat/components/AgentMessage.tsx +++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx @@ -1,6 +1,8 @@ +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; import { Brain } from "phosphor-react-native"; -import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useState } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { usePeriodicRerender } from "../hooks/usePeriodicRerender"; import type { AssistantToolCall } from "../types"; @@ -74,6 +76,14 @@ export function AgentMessage({ const hasContent = !!content; const isComplete = !isLoading && hasContent; + const handleLongPress = useCallback(() => { + if (!content) return; + Clipboard.setStringAsync(content).then(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Copied", "Message copied to clipboard."); + }); + }, [content]); + return ( {toolCalls && toolCalls.length > 0 && ( @@ -105,11 +115,13 @@ export function AgentMessage({ )} - {/* Show final content */} + {/* Show final content — long-press to copy */} {content && ( - - - + + + + + )} ); diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 432af4b20..8a71c137f 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -1,4 +1,10 @@ -import { ArrowDown, Brain, CaretRight, CloudArrowDown, Robot } from "phosphor-react-native"; +import { + ArrowDown, + Brain, + CaretRight, + CloudArrowDown, + Robot, +} from "phosphor-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, diff --git a/apps/mobile/src/hooks/useNetworkStatus.ts b/apps/mobile/src/hooks/useNetworkStatus.ts new file mode 100644 index 000000000..fc183d7b1 --- /dev/null +++ b/apps/mobile/src/hooks/useNetworkStatus.ts @@ -0,0 +1,15 @@ +import NetInfo from "@react-native-community/netinfo"; +import { useEffect, useState } from "react"; + +export function useNetworkStatus() { + const [isConnected, setIsConnected] = useState(true); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + setIsConnected(state.isConnected ?? true); + }); + return unsubscribe; + }, []); + + return { isConnected }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7800234b6..1056ff12d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: '@react-native-async-storage/async-storage': specifier: ^2.2.0 version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) + '@react-native-community/netinfo': + specifier: ^12.0.1 + version: 12.0.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) '@tanstack/react-query': specifier: ^5.90.12 version: 5.90.20(react@19.1.0) @@ -530,6 +533,9 @@ importers: expo-av: specifier: ~16.0.8 version: 16.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-clipboard: + specifier: ^55.0.13 + version: 55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.11 version: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) @@ -551,6 +557,9 @@ importers: expo-glass-effect: specifier: ~0.1.8 version: 0.1.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@54.0.33) expo-linear-gradient: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) @@ -4376,6 +4385,12 @@ packages: peerDependencies: react-native: ^0.0.0-0 || >=0.65 <1.0 + '@react-native-community/netinfo@12.0.1': + resolution: {integrity: sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==} + peerDependencies: + react: '*' + react-native: '>=0.59' + '@react-native/assets-registry@0.81.5': resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==} engines: {node: '>= 20.19.4'} @@ -6873,6 +6888,13 @@ packages: react-native-web: optional: true + expo-clipboard@55.0.13: + resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.13: resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} peerDependencies: @@ -6929,6 +6951,11 @@ packages: react: '*' react-native: '*' + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + expo-json-utils@0.15.0: resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==} @@ -15956,6 +15983,11 @@ snapshots: merge-options: 3.0.4 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + '@react-native-community/netinfo@12.0.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)': + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + '@react-native/assets-registry@0.81.5': {} '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': @@ -18648,6 +18680,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-clipboard@55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-constants@18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 @@ -18714,6 +18752,10 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-haptics@55.0.14(expo@54.0.33): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-json-utils@0.15.0: {} expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): @@ -21657,7 +21699,7 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 path-scurry@2.0.1: dependencies: From 5e021ceb7732a68cf5ddac79b508767af7748d87 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 13:23:51 +0100 Subject: [PATCH 17/39] WIP - Add highlighting and timestamps to messages --- apps/mobile/package.json | 1 + .../features/chat/components/AgentMessage.tsx | 20 ++ .../features/chat/components/HumanMessage.tsx | 42 +++- .../features/chat/components/MarkdownText.tsx | 64 +++++- .../features/chat/components/ToolMessage.tsx | 92 +++++++-- .../tasks/components/TaskSessionView.tsx | 19 +- apps/mobile/src/lib/syntax-highlight.ts | 191 ++++++++++++++++++ pnpm-lock.yaml | 9 + 8 files changed, 408 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/src/lib/syntax-highlight.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 568da076b..2475feace 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -50,6 +50,7 @@ "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-web-browser": "^15.0.10", + "highlight.js": "^11.11.1", "nativewind": "^4.2.1", "phosphor-react-native": "^3.0.2", "posthog-react-native": "^4.18.0", diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx index 11b0469c3..1c4ee79ba 100644 --- a/apps/mobile/src/features/chat/components/AgentMessage.tsx +++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx @@ -17,6 +17,7 @@ interface AgentMessageProps { toolCalls?: AssistantToolCall[]; hasHumanMessageAfter?: boolean; onOpenTask?: (taskId: string) => void; + timestamp?: number; } interface ReasoningBlockProps { @@ -63,6 +64,18 @@ function ReasoningBlock({ content, isComplete }: ReasoningBlockProps) { const THINKING_MESSAGE_INTERVAL_MS = 2000; +function formatRelativeTime(ts: number): string { + const diffMs = Date.now() - ts; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + export function AgentMessage({ content, isLoading, @@ -70,6 +83,7 @@ export function AgentMessage({ toolCalls, hasHumanMessageAfter, onOpenTask, + timestamp, }: AgentMessageProps) { usePeriodicRerender(isLoading ? THINKING_MESSAGE_INTERVAL_MS : 0); @@ -123,6 +137,12 @@ export function AgentMessage({ )} + + {timestamp && !isLoading && ( + + {formatRelativeTime(timestamp)} + + )} ); } diff --git a/apps/mobile/src/features/chat/components/HumanMessage.tsx b/apps/mobile/src/features/chat/components/HumanMessage.tsx index 185a64f13..2e2ce0a75 100644 --- a/apps/mobile/src/features/chat/components/HumanMessage.tsx +++ b/apps/mobile/src/features/chat/components/HumanMessage.tsx @@ -1,17 +1,47 @@ -import { Text, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { useCallback } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; interface HumanMessageProps { content: string; + timestamp?: number; } -export function HumanMessage({ content }: HumanMessageProps) { +function formatRelativeTime(ts: number): string { + const diffMs = Date.now() - ts; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +export function HumanMessage({ content, timestamp }: HumanMessageProps) { + const handleLongPress = useCallback(() => { + Clipboard.setStringAsync(content).then(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Copied", "Message copied to clipboard."); + }); + }, [content]); + return ( - - - {content} + + + + {content} + + + + {timestamp && ( + + {formatRelativeTime(timestamp)} - + )} ); } diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index ad386e9d5..d28bd164b 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -1,15 +1,53 @@ +import { useMemo } from "react"; import { ScrollView, Text, View } from "react-native"; +import { getColorForClass, highlightCode } from "@/lib/syntax-highlight"; +import { useThemeColors } from "@/lib/theme"; interface MarkdownTextProps { content: string; } -// Lightweight markdown renderer for agent messages. -// Handles: code blocks, inline code, bold, italic, headers, bullet/numbered lists, tables. +function HighlightedCode({ + code, + language, +}: { + code: string; + language: string; +}) { + const themeColors = useThemeColors(); + const segments = useMemo( + () => highlightCode(code, language), + [code, language], + ); + + if (!segments) { + return ( + + {code} + + ); + } + + return ( + + {segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +} interface Block { type: "paragraph" | "code" | "heading" | "list" | "table"; content: string; + language?: string; level?: number; items?: string[]; ordered?: boolean; @@ -26,6 +64,7 @@ function parseBlocks(text: string): Block[] { // Code block if (line.startsWith("```")) { + const language = line.slice(3).trim() || undefined; const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].startsWith("```")) { @@ -33,7 +72,7 @@ function parseBlocks(text: string): Block[] { i++; } i++; // skip closing ``` - blocks.push({ type: "code", content: codeLines.join("\n") }); + blocks.push({ type: "code", content: codeLines.join("\n"), language }); continue; } @@ -188,12 +227,19 @@ export function MarkdownText({ content }: MarkdownTextProps) { key={key} className="rounded-md border border-gray-6 bg-gray-3 px-3 py-2" > - - {block.content} - + {block.language ? ( + + ) : ( + + {block.content} + + )} ); diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index a5ab7f8a8..e75f8494e 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -14,7 +14,7 @@ import { Trash, Wrench, } from "phosphor-react-native"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, Pressable, @@ -22,6 +22,11 @@ import { TouchableOpacity, View, } from "react-native"; +import { + getColorForClass, + highlightCode, + languageFromPath, +} from "@/lib/syntax-highlight"; import { useThemeColors } from "@/lib/theme"; export type ToolStatus = "pending" | "running" | "completed" | "error"; @@ -517,12 +522,55 @@ function computeLineDiff( interface DiffBlockProps { oldText: string; newText: string; + language?: string | null; maxLines?: number; } -function DiffBlock({ oldText, newText, maxLines = 60 }: DiffBlockProps) { +function HighlightedDiffLine({ + text, + language, + fallbackColor, +}: { + text: string; + language?: string | null; + fallbackColor: string; +}) { + const segments = useMemo( + () => (language ? highlightCode(text, language) : null), + [text, language], + ); + + if (!segments) { + return <>{text || " "}; + } + + return ( + <> + {segments.map((seg, i) => { + const color = getColorForClass(seg.className); + return ( + + {seg.text} + + ); + })} + + ); +} + +function DiffBlock({ + oldText, + newText, + language, + maxLines = 60, +}: DiffBlockProps) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); const allLines = computeLineDiff(oldText, newText); - const truncated = allLines.length > maxLines; + const truncated = !expanded && allLines.length > maxLines; const lines = truncated ? allLines.slice(0, maxLines) : allLines; return ( @@ -540,26 +588,36 @@ function DiffBlock({ oldText, newText, maxLines = 60 }: DiffBlockProps) { ); } let cls = "font-mono text-[11px] leading-4 px-2"; + const fallbackColor = + line.kind === "added" + ? themeColors.status.success + : line.kind === "removed" + ? themeColors.status.error + : themeColors.gray[11]; if (line.kind === "added") { - cls += " bg-status-success/10 text-status-success"; + cls += " bg-status-success/10"; } else if (line.kind === "removed") { - cls += " bg-status-error/10 text-status-error"; - } else { - cls += " text-gray-11"; + cls += " bg-status-error/10"; } const prefix = line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "; return ( - {prefix} - {line.text || " "} + {prefix} + ); })} {truncated && ( - - … {allLines.length - maxLines} more lines - + setExpanded(true)}> + + Show all {allLines.length} lines + + )} ); @@ -776,6 +834,7 @@ export function ToolMessage({ )} {multiEditArgs?.edits.map((edit, i) => ( @@ -783,9 +842,16 @@ export function ToolMessage({ key={`${multiEditArgs.file_path}-${i}`} oldText={edit.old_string} newText={edit.new_string} + language={languageFromPath(fileToolArgs.file_path)} /> ))} - {writeArgs && } + {writeArgs && ( + + )} )} diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 8a71c137f..61003ee1c 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -59,6 +59,7 @@ interface ParsedMessage { id: string; type: "user" | "agent" | "thought" | "tool" | "connecting" | "thinking"; content: string; + ts?: number; toolData?: ToolData; children?: ParsedMessage[]; } @@ -181,6 +182,7 @@ interface EventProcessorState { messages: ParsedMessage[]; plan: PlanEntry[] | null; pendingAgentText: string; + pendingAgentTs?: number; pendingThoughtText: string; lastAgentMsgIdx: number | null; agentMessageCount: number; @@ -235,10 +237,12 @@ function processNewEvents( id: `agent-${state.agentMessageCount++}`, type: "agent", content: state.pendingAgentText, + ts: state.pendingAgentTs, }; state.messages.push(msg); state.lastAgentMsgIdx = state.messages.length - 1; state.pendingAgentText = ""; + state.pendingAgentTs = undefined; }; const flushThoughtText = () => { @@ -278,11 +282,13 @@ function processNewEvents( id: `user-${state.userMessageCount++}`, type: "user", content: parsed.content ?? "", + ts: event.ts, }); state.lastAgentMsgIdx = null; break; case "agent": flushThoughtText(); + if (!state.pendingAgentTs) state.pendingAgentTs = event.ts; state.pendingAgentText += parsed.content ?? ""; break; case "agent_complete": @@ -293,9 +299,14 @@ function processNewEvents( state.messages[state.lastAgentMsgIdx]?.type === "agent" ) { state.messages[state.lastAgentMsgIdx].content = parsed.content ?? ""; + if (!state.messages[state.lastAgentMsgIdx].ts) { + state.messages[state.lastAgentMsgIdx].ts = event.ts; + } state.pendingAgentText = ""; + state.pendingAgentTs = undefined; } else { state.pendingAgentText = parsed.content ?? ""; + if (!state.pendingAgentTs) state.pendingAgentTs = event.ts; } break; case "thought": @@ -705,10 +716,14 @@ export function TaskSessionView({ ({ item }: { item: ParsedMessage }) => { switch (item.type) { case "user": - return ; + return ; case "agent": return ( - + ); case "thought": return ; diff --git a/apps/mobile/src/lib/syntax-highlight.ts b/apps/mobile/src/lib/syntax-highlight.ts new file mode 100644 index 000000000..6aba77229 --- /dev/null +++ b/apps/mobile/src/lib/syntax-highlight.ts @@ -0,0 +1,191 @@ +import hljs from "highlight.js/lib/core"; +import cpp from "highlight.js/lib/languages/cpp"; +import css from "highlight.js/lib/languages/css"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import markdown from "highlight.js/lib/languages/markdown"; +import php from "highlight.js/lib/languages/php"; +import python from "highlight.js/lib/languages/python"; +import ruby from "highlight.js/lib/languages/ruby"; +import rust from "highlight.js/lib/languages/rust"; +import scss from "highlight.js/lib/languages/scss"; +import shell from "highlight.js/lib/languages/shell"; +import sql from "highlight.js/lib/languages/sql"; +import swift from "highlight.js/lib/languages/swift"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; + +hljs.registerLanguage("cpp", cpp); +hljs.registerLanguage("c", cpp); +hljs.registerLanguage("css", css); +hljs.registerLanguage("go", go); +hljs.registerLanguage("golang", go); +hljs.registerLanguage("java", java); +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("js", javascript); +hljs.registerLanguage("jsx", javascript); +hljs.registerLanguage("json", json); +hljs.registerLanguage("markdown", markdown); +hljs.registerLanguage("md", markdown); +hljs.registerLanguage("php", php); +hljs.registerLanguage("python", python); +hljs.registerLanguage("py", python); +hljs.registerLanguage("ruby", ruby); +hljs.registerLanguage("rb", ruby); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("rs", rust); +hljs.registerLanguage("scss", scss); +hljs.registerLanguage("sass", scss); +hljs.registerLanguage("shell", shell); +hljs.registerLanguage("bash", shell); +hljs.registerLanguage("sh", shell); +hljs.registerLanguage("zsh", shell); +hljs.registerLanguage("sql", sql); +hljs.registerLanguage("swift", swift); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("ts", typescript); +hljs.registerLanguage("tsx", typescript); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("html", xml); +hljs.registerLanguage("svg", xml); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("yml", yaml); + +export interface HighlightSegment { + text: string; + className?: string; +} + +// One Dark palette matching the desktop app +const ONE_DARK_COLORS: Record = { + "hljs-keyword": "#c678dd", + "hljs-built_in": "#e5c07b", + "hljs-type": "#e5c07b", + "hljs-literal": "#d19a66", + "hljs-number": "#d19a66", + "hljs-string": "#98c379", + "hljs-regexp": "#56b6c2", + "hljs-comment": "#8a8275", + "hljs-doctag": "#c678dd", + "hljs-function": "#61afef", + "hljs-title": "#61afef", + "hljs-title.function_": "#61afef", + "hljs-params": "#c4baa8", + "hljs-variable": "#e06c75", + "hljs-attr": "#d19a66", + "hljs-attribute": "#d19a66", + "hljs-name": "#e06c75", + "hljs-tag": "#e06c75", + "hljs-selector-tag": "#e06c75", + "hljs-selector-class": "#e5c07b", + "hljs-selector-id": "#61afef", + "hljs-property": "#e06c75", + "hljs-meta": "#56b6c2", + "hljs-operator": "#56b6c2", + "hljs-punctuation": "#c4baa8", + "hljs-subst": "#c4baa8", + "hljs-symbol": "#56b6c2", + "hljs-addition": "#98c379", + "hljs-deletion": "#e06c75", +}; + +function decodeEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +/** + * Parse highlight.js HTML output into segments. + * Input: `const x = 42;` + */ +function parseHljsHtml(html: string): HighlightSegment[] { + const segments: HighlightSegment[] = []; + const regex = /([\s\S]*?)<\/span>|([^<]+)/g; + + for (let match = regex.exec(html); match !== null; match = regex.exec(html)) { + if (match[3]) { + segments.push({ text: decodeEntities(match[3]) }); + } else if (match[1] && match[2] !== undefined) { + const className = match[1]; + const inner = match[2]; + if (inner.includes(" = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + go: "go", + rs: "rust", + rb: "ruby", + java: "java", + c: "c", + cpp: "cpp", + h: "cpp", + hpp: "cpp", + css: "css", + scss: "scss", + html: "html", + xml: "xml", + svg: "xml", + json: "json", + yaml: "yaml", + yml: "yaml", + md: "markdown", + sql: "sql", + sh: "shell", + bash: "shell", + zsh: "shell", + swift: "swift", + php: "php", +}; + +export function languageFromPath(filePath: string): string | null { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return null; + return EXT_TO_LANGUAGE[ext] ?? null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1056ff12d..1b814478c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,6 +590,9 @@ importers: expo-web-browser: specifier: ^15.0.10 version: 15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 nativewind: specifier: ^4.2.1 version: 4.2.1(react-native-reanimated@4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) @@ -7591,6 +7594,10 @@ packages: hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono@4.11.7: resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} engines: {node: '>=16.9.0'} @@ -19553,6 +19560,8 @@ snapshots: dependencies: hermes-estree: 0.32.0 + highlight.js@11.11.1: {} + hono@4.11.7: {} hosted-git-info@2.8.9: {} From 94abba51278eaf600d89e27d81b8823f5951b067 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Tue, 14 Apr 2026 23:33:49 +0100 Subject: [PATCH 18/39] fix: Introduce dev sign in bypass --- .claude/settings.json | 5 +- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + .../services/agent/local-command-receiver.ts | 262 ++++++ apps/code/src/main/services/agent/schemas.ts | 30 +- .../src/main/services/agent/service.test.ts | 123 ++- apps/code/src/main/services/agent/service.ts | 122 +++ apps/code/src/main/trpc/routers/agent.ts | 20 + .../features/sessions/service/service.test.ts | 3 + .../features/sessions/service/service.ts | 27 + apps/mobile/app.json | 14 +- apps/mobile/assets/sounds/meep.mp3 | Bin 0 -> 7941 bytes apps/mobile/index.js | 1 + apps/mobile/package.json | 7 +- apps/mobile/src/app/(tabs)/_layout.tsx | 30 +- apps/mobile/src/app/(tabs)/index.tsx | 8 +- apps/mobile/src/app/(tabs)/settings.tsx | 36 + apps/mobile/src/app/(tabs)/tasks.tsx | 39 +- apps/mobile/src/app/_layout.tsx | 46 +- apps/mobile/src/app/auth.tsx | 86 +- apps/mobile/src/app/task/[id].tsx | 227 ++++- apps/mobile/src/app/task/index.tsx | 83 +- apps/mobile/src/components/OfflineBanner.tsx | 23 + .../src/features/auth/stores/authStore.ts | 42 +- .../features/chat/components/AgentMessage.tsx | 47 +- .../src/features/chat/components/Composer.tsx | 143 ++- .../features/chat/components/HumanMessage.tsx | 42 +- .../features/chat/components/MarkdownText.tsx | 372 ++++++++ .../features/chat/components/ToolMessage.tsx | 872 +++++++++++++++++- .../features/chat/hooks/useVoiceRecording.ts | 212 ++--- apps/mobile/src/features/chat/index.ts | 2 +- .../preferences/stores/preferencesStore.ts | 29 + apps/mobile/src/features/tasks/api.ts | 165 +++- .../tasks/components/PlanStatusBar.tsx | 88 ++ .../tasks/components/QuestionCard.tsx | 378 ++++++++ .../tasks/components/SwipeableTaskItem.tsx | 143 +++ .../features/tasks/components/TaskItem.tsx | 15 +- .../features/tasks/components/TaskList.tsx | 101 +- .../tasks/components/TaskSessionView.tsx | 768 +++++++++++++-- .../src/features/tasks/hooks/useTasks.ts | 17 +- .../tasks/stores/archivedTasksStore.ts | 40 + .../features/tasks/stores/taskSessionStore.ts | 721 ++++++++++++--- apps/mobile/src/features/tasks/types.ts | 13 + .../features/tasks/utils/parseSessionLogs.ts | 17 - .../mobile/src/features/tasks/utils/sounds.ts | 23 + apps/mobile/src/hooks/useNetworkStatus.ts | 15 + apps/mobile/src/lib/syntax-highlight.ts | 192 ++++ packages/agent/src/acp-extensions.ts | 8 + .../claude/permissions/permission-handlers.ts | 35 +- packages/agent/src/server/agent-server.ts | 142 ++- .../agent/src/server/question-relay.test.ts | 5 +- pnpm-lock.yaml | 69 +- 52 files changed, 5320 insertions(+), 591 deletions(-) create mode 100644 apps/code/src/main/services/agent/local-command-receiver.ts create mode 100644 apps/mobile/assets/sounds/meep.mp3 create mode 100644 apps/mobile/index.js create mode 100644 apps/mobile/src/components/OfflineBanner.tsx create mode 100644 apps/mobile/src/features/chat/components/MarkdownText.tsx create mode 100644 apps/mobile/src/features/preferences/stores/preferencesStore.ts create mode 100644 apps/mobile/src/features/tasks/components/PlanStatusBar.tsx create mode 100644 apps/mobile/src/features/tasks/components/QuestionCard.tsx create mode 100644 apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx create mode 100644 apps/mobile/src/features/tasks/stores/archivedTasksStore.ts create mode 100644 apps/mobile/src/features/tasks/utils/sounds.ts create mode 100644 apps/mobile/src/hooks/useNetworkStatus.ts create mode 100644 apps/mobile/src/lib/syntax-highlight.ts diff --git a/.claude/settings.json b/.claude/settings.json index a98a38348..e73b7b901 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,10 +4,7 @@ "deny": [ "Read(./apps/cli/**)", "Edit(./apps/cli/**)", - "Write(./apps/cli/**)", - "Read(./apps/mobile/**)", - "Edit(./apps/mobile/**)", - "Write(./apps/mobile/**)" + "Write(./apps/cli/**)" ] } } diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index f45163c53..2a688ba2a 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -10,6 +10,7 @@ import { WorkspaceRepository } from "../db/repositories/workspace-repository"; import { WorktreeRepository } from "../db/repositories/worktree-repository"; import { DatabaseService } from "../db/service"; import { AgentAuthAdapter } from "../services/agent/auth-adapter"; +import { LocalCommandReceiver } from "../services/agent/local-command-receiver"; import { AgentService } from "../services/agent/service"; import { AppLifecycleService } from "../services/app-lifecycle/service"; import { ArchiveService } from "../services/archive/service"; @@ -64,6 +65,7 @@ container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository); container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl); container.bind(MAIN_TOKENS.AgentAuthAdapter).to(AgentAuthAdapter); container.bind(MAIN_TOKENS.AgentService).to(AgentService); +container.bind(MAIN_TOKENS.LocalCommandReceiver).to(LocalCommandReceiver); container.bind(MAIN_TOKENS.AuthService).to(AuthService); container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService); container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 27bdbcafc..5308d78f4 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -21,6 +21,7 @@ export const MAIN_TOKENS = Object.freeze({ // Services AgentAuthAdapter: Symbol.for("Main.AgentAuthAdapter"), AgentService: Symbol.for("Main.AgentService"), + LocalCommandReceiver: Symbol.for("Main.LocalCommandReceiver"), AuthService: Symbol.for("Main.AuthService"), AuthProxyService: Symbol.for("Main.AuthProxyService"), ArchiveService: Symbol.for("Main.ArchiveService"), diff --git a/apps/code/src/main/services/agent/local-command-receiver.ts b/apps/code/src/main/services/agent/local-command-receiver.ts new file mode 100644 index 000000000..8a3b1d186 --- /dev/null +++ b/apps/code/src/main/services/agent/local-command-receiver.ts @@ -0,0 +1,262 @@ +import { inject, injectable, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import type { AuthService } from "../auth/service"; + +const log = logger.scope("local-command-receiver"); +const INITIAL_RECONNECT_DELAY_MS = 2000; +const MAX_RECONNECT_DELAY_MS = 30_000; +// After this many consecutive failures, assume Last-Event-ID is stale (event +// trimmed from the backend buffer) and fall back to a fresh connect with +// start=latest. Accepts that we may drop commands issued during the outage. +const STALE_EVENT_ID_THRESHOLD = 3; + +/** + * JSON-RPC envelope carried inside an `incoming_command` SSE event. The + * backend repackages whatever mobile POSTs to /command/ into this shape + * (see products/tasks/backend/api.py :: command). + */ +export interface IncomingCommandPayload { + jsonrpc: string; + method: string; + params?: { content?: string } & Record; + id?: string | number; +} + +interface SubscribeParams { + taskId: string; + taskRunId: string; + projectId: number; + apiHost: string; + onCommand: (payload: IncomingCommandPayload) => Promise; +} + +interface Subscription { + taskRunId: string; + controller: AbortController; +} + +/** + * Subscribes to the PostHog task-run SSE stream for a local run and + * delivers `incoming_command` events (published by the backend when mobile + * POSTs to /command/ on a run with environment=local) to a caller-supplied + * callback. + * + * One SSE connection per subscribed run. Reconnects with backoff on failure. + * Uses the `Last-Event-ID` header to resume from the last processed event + * so brief network blips don't drop commands. + */ +@injectable() +export class LocalCommandReceiver { + private readonly subs = new Map(); + + constructor( + @inject(MAIN_TOKENS.AuthService) + private readonly auth: AuthService, + ) {} + + subscribe(params: SubscribeParams): void { + if (this.subs.has(params.taskRunId)) { + log.debug("Already subscribed", { taskRunId: params.taskRunId }); + return; + } + const controller = new AbortController(); + this.subs.set(params.taskRunId, { + taskRunId: params.taskRunId, + controller, + }); + log.info("Subscribing to SSE stream", { taskRunId: params.taskRunId }); + void this.connectLoop(params, controller).catch((err) => { + if (controller.signal.aborted) return; + log.error("Connect loop exited unexpectedly", { + taskRunId: params.taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + unsubscribe(taskRunId: string): void { + const sub = this.subs.get(taskRunId); + if (!sub) return; + sub.controller.abort(); + this.subs.delete(taskRunId); + log.info("Unsubscribed", { taskRunId }); + } + + @preDestroy() + async shutdown(): Promise { + // Abort before awaiting teardown — per async-cleanup-ordering guidance. + for (const sub of this.subs.values()) sub.controller.abort(); + this.subs.clear(); + } + + private async connectLoop( + params: SubscribeParams, + controller: AbortController, + ): Promise { + let lastEventId: string | undefined; + let consecutiveFailures = 0; + + while (!controller.signal.aborted) { + let streamOpened = false; + try { + const { accessToken } = await this.auth.getValidAccessToken(); + const url = new URL( + `${params.apiHost}/api/projects/${params.projectId}/tasks/${params.taskId}/runs/${params.taskRunId}/stream/`, + ); + if (!lastEventId) { + // Fresh connect: only care about events published from now on. + // On reconnect we use Last-Event-ID instead (see headers below). + url.searchParams.set("start", "latest"); + } + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "text/event-stream", + }; + if (lastEventId) headers["Last-Event-ID"] = lastEventId; + + const response = await fetch(url.toString(), { + headers, + signal: controller.signal, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `SSE HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`, + ); + } + + streamOpened = true; + consecutiveFailures = 0; + lastEventId = await this.readEventStream( + response.body, + params.onCommand, + controller.signal, + lastEventId, + ); + log.info("SSE stream ended cleanly", { + taskRunId: params.taskRunId, + }); + } catch (err) { + if (controller.signal.aborted) return; + if (!streamOpened) consecutiveFailures++; + if ( + consecutiveFailures >= STALE_EVENT_ID_THRESHOLD && + lastEventId !== undefined + ) { + log.warn( + "Dropping possibly-stale Last-Event-ID after repeated failures", + { + taskRunId: params.taskRunId, + consecutiveFailures, + }, + ); + lastEventId = undefined; + } + log.warn("SSE disconnected, will reconnect", { + taskRunId: params.taskRunId, + consecutiveFailures, + error: err instanceof Error ? err.message : String(err), + }); + } + if (controller.signal.aborted) return; + const delay = Math.min( + MAX_RECONNECT_DELAY_MS, + INITIAL_RECONNECT_DELAY_MS * 2 ** Math.max(0, consecutiveFailures - 1), + ); + await this.sleep(delay, controller.signal); + } + } + + private async readEventStream( + body: ReadableStream | null, + onCommand: SubscribeParams["onCommand"], + signal: AbortSignal, + seedLastEventId: string | undefined, + ): Promise { + if (!body) throw new Error("Missing SSE response body"); + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastEventId = seedLastEventId; + + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) return lastEventId; + buffer += decoder.decode(value, { stream: true }); + + // SSE event blocks are separated by a blank line (\n\n). + while (true) { + const separator = buffer.indexOf("\n\n"); + if (separator === -1) break; + const rawEvent = buffer.slice(0, separator); + buffer = buffer.slice(separator + 2); + + let dataChunks = ""; + let eventId: string | undefined; + for (const line of rawEvent.split("\n")) { + if (line.startsWith("data: ")) { + dataChunks += line.slice(6); + } else if (line.startsWith("id: ")) { + eventId = line.slice(4); + } + // `event:` and comments are ignored — we route on data.type. + } + if (!dataChunks) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(dataChunks); + } catch { + log.warn("Failed to parse SSE data chunk", { + preview: dataChunks.slice(0, 120), + }); + continue; + } + + if ( + typeof parsed === "object" && + parsed !== null && + (parsed as { type?: unknown }).type === "incoming_command" + ) { + const payload = (parsed as { payload?: unknown }).payload; + if (payload && typeof payload === "object") { + try { + await onCommand(payload as IncomingCommandPayload); + } catch (err) { + log.error("Incoming command handler threw", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + if (eventId) lastEventId = eventId; + } + } + return lastEventId; + } finally { + try { + await reader.cancel(); + } catch { + // Reader already closed or cancelled; nothing to do. + } + } + } + + private sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + } +} diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 3ead6cf15..de161a862 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -16,24 +16,6 @@ export const credentialsSchema = z.object({ export type Credentials = z.infer; -// Session config schema -export const sessionConfigSchema = z.object({ - taskId: z.string(), - taskRunId: z.string(), - repoPath: z.string(), - credentials: credentialsSchema, - logUrl: z.string().optional(), - /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ - sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), - /** Additional directories Claude can access beyond cwd (for worktree support) */ - additionalDirectories: z.array(z.string()).optional(), - /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ - permissionMode: z.string().optional(), -}); - -export type SessionConfig = z.infer; - // Start session input/output export const startSessionInput = z.object({ @@ -175,6 +157,7 @@ export const reconnectSessionInput = z.object({ customInstructions: z.string().max(2000).optional(), effort: effortLevelSchema.optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), + runMode: z.enum(["local", "cloud"]).optional(), }); export type ReconnectSessionInput = z.infer; @@ -200,6 +183,11 @@ export const recordActivityInput = z.object({ export const AgentServiceEvent = { SessionEvent: "session-event", PermissionRequest: "permission-request", + // Fires when a pending permission is resolved by anything other than the + // Electron UI (e.g. a mobile client calling permission_response). Renderer + // uses this to clear its own pendingPermissions copy in lockstep with the + // main-process map. + PermissionResolved: "permission-resolved", SessionsIdle: "sessions-idle", SessionIdleKilled: "session-idle-killed", AgentFileActivity: "agent-file-activity", @@ -228,9 +216,15 @@ export interface AgentFileActivityPayload { branchName: string | null; } +export interface PermissionResolvedPayload { + taskRunId: string; + toolCallId: string; +} + export interface AgentServiceEvents { [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; + [AgentServiceEvent.PermissionResolved]: PermissionResolvedPayload; [AgentServiceEvent.SessionsIdle]: undefined; [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 02e1642cc..8cc5789f7 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -176,6 +176,11 @@ function createMockDependencies() { notifyToolResult: vi.fn(), notifyToolCancelled: vi.fn(), }, + localCommandReceiver: { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, }; } @@ -189,11 +194,12 @@ const baseSessionParams = { describe("AgentService", () => { let service: AgentService; + let deps: ReturnType; beforeEach(() => { vi.clearAllMocks(); - const deps = createMockDependencies(); + deps = createMockDependencies(); service = new AgentService( deps.processTracking as never, deps.sleepService as never, @@ -201,6 +207,7 @@ describe("AgentService", () => { deps.posthogPluginService as never, deps.agentAuthAdapter as never, deps.mcpAppsService as never, + deps.localCommandReceiver as never, ); }); @@ -439,4 +446,118 @@ describe("AgentService", () => { ); }); }); + + describe("local runMode", () => { + it("subscribes to local command receiver when runMode is local", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + expect(deps.localCommandReceiver.subscribe).toHaveBeenCalledTimes(1); + const call = deps.localCommandReceiver.subscribe.mock.calls[0][0]; + expect(call).toMatchObject({ + taskId: "task-1", + taskRunId: "run-1", + projectId: 1, + apiHost: "https://app.posthog.com", + }); + expect(typeof call.onCommand).toBe("function"); + }); + + it("does not subscribe when runMode is cloud", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "cloud", + }); + + expect(deps.localCommandReceiver.subscribe).not.toHaveBeenCalled(); + }); + + it("delivers user_message commands to the session via prompt", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ + jsonrpc: "2.0", + method: "user_message", + params: { content: "hello from mobile" }, + }); + + expect(promptSpy).toHaveBeenCalledWith("run-1", [ + { type: "text", text: "hello from mobile" }, + ]); + }); + + it("ignores non-user_message commands", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ + jsonrpc: "2.0", + method: "something_else", + params: { content: "ignored" }, + }); + + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it("ignores commands with missing or non-string content", async () => { + const promptSpy = vi + .spyOn(service, "prompt") + .mockResolvedValue(undefined as never); + + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + const onCommand = deps.localCommandReceiver.subscribe.mock.calls[0][0] + .onCommand as (payload: unknown) => Promise; + + await onCommand({ jsonrpc: "2.0", method: "user_message", params: {} }); + await onCommand({ + jsonrpc: "2.0", + method: "user_message", + params: { content: "" }, + }); + + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it("unsubscribes on session cleanup", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + await ( + service as unknown as { + cleanupSession: (id: string) => Promise; + } + ).cleanupSession("run-1"); + + expect(deps.localCommandReceiver.unsubscribe).toHaveBeenCalledWith( + "run-1", + ); + }); + }); }); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index d2fb6eed3..ad2cadd40 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -51,6 +51,7 @@ import type { ProcessTrackingService } from "../process-tracking/service"; import type { SleepService } from "../sleep/service"; import type { AgentAuthAdapter } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; +import type { LocalCommandReceiver } from "./local-command-receiver"; import { AgentServiceEvent, type AgentServiceEvents, @@ -257,6 +258,8 @@ interface SessionConfig { model?: string; /** JSON Schema for structured task output — when set, the agent gets a create_output tool */ jsonSchema?: Record | null; + /** Whether this session runs locally on the desktop or in a cloud sandbox */ + runMode?: "local" | "cloud"; } interface ManagedSession { @@ -324,6 +327,7 @@ export class AgentService extends TypedEventEmitter { private posthogPluginService: PosthogPluginService; private agentAuthAdapter: AgentAuthAdapter; private mcpAppsService: McpAppsService; + private localCommandReceiver: LocalCommandReceiver; constructor( @inject(MAIN_TOKENS.ProcessTrackingService) @@ -338,6 +342,8 @@ export class AgentService extends TypedEventEmitter { agentAuthAdapter: AgentAuthAdapter, @inject(MAIN_TOKENS.McpAppsService) mcpAppsService: McpAppsService, + @inject(MAIN_TOKENS.LocalCommandReceiver) + localCommandReceiver: LocalCommandReceiver, ) { super(); this.processTracking = processTracking; @@ -346,6 +352,7 @@ export class AgentService extends TypedEventEmitter { this.posthogPluginService = posthogPluginService; this.agentAuthAdapter = agentAuthAdapter; this.mcpAppsService = mcpAppsService; + this.localCommandReceiver = localCommandReceiver; powerMonitor.on("resume", () => this.checkIdleDeadlines()); } @@ -390,6 +397,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -418,6 +426,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -822,6 +831,15 @@ When creating pull requests, add the following footer at the end of the PR descr this.sessions.set(taskRunId, session); this.recordActivity(taskRunId); + if (config.runMode === "local") { + this.ensureLocalCommandSubscription( + taskId, + taskRunId, + credentials.projectId, + credentials.apiHost, + ); + } + if (isRetry) { log.info("Session created after auth retry", { taskRunId }); } @@ -1161,7 +1179,110 @@ For git operations while detached: session.inFlightMcpToolCalls.clear(); } + /** + * Idempotently subscribe the LocalCommandReceiver to a task-run's SSE + * stream so mobile-originated /command/ calls reach this session. Called + * both on fresh session creation and when an existing session is reused + * — the receiver itself short-circuits duplicate subscribes, so multiple + * calls are safe. Without this, a session that existed before this code + * path shipped (or that had its subscription torn down by an earlier + * cleanup) would silently drop mobile commands. + */ + private ensureLocalCommandSubscription( + taskId: string, + taskRunId: string, + projectId: number, + apiHost: string, + ): void { + this.localCommandReceiver.subscribe({ + taskId, + taskRunId, + projectId, + apiHost, + onCommand: async (payload) => { + log.debug("Local command received", { + taskRunId, + method: payload.method, + }); + + // Mobile (or any external client) answering an outstanding + // requestPermission call. Route it directly to the pending + // promise rather than treating it as a new prompt — otherwise + // the agent stays blocked inside the current turn and the + // answer starts a second turn that can never run. + if (payload.method === "permission_response") { + const params = payload.params ?? {}; + const toolCallId = + typeof params.toolCallId === "string" + ? params.toolCallId + : undefined; + const optionId = + typeof params.optionId === "string" ? params.optionId : undefined; + const customInput = + typeof params.customInput === "string" + ? params.customInput + : undefined; + const rawAnswers = params.answers; + const answers = + rawAnswers && typeof rawAnswers === "object" + ? (rawAnswers as Record) + : undefined; + if (!toolCallId || !optionId) { + log.warn("Invalid permission_response from external client", { + taskRunId, + hasToolCallId: !!toolCallId, + hasOptionId: !!optionId, + }); + return; + } + try { + this.respondToPermission( + taskRunId, + toolCallId, + optionId, + customInput, + answers, + ); + } catch (err) { + log.error("Failed to apply external permission_response", { + taskRunId, + toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + } + return; + } + + if (payload.method !== "user_message") { + log.debug("Ignoring non-user_message local command", { + method: payload.method, + taskRunId, + }); + return; + } + const content = payload.params?.content; + if (typeof content !== "string" || content.length === 0) { + log.warn("Local command missing content", { taskRunId }); + return; + } + try { + await this.prompt(taskRunId, [{ type: "text", text: content }]); + } catch (err) { + log.error("Failed to deliver local command to session", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + }); + } + private async cleanupSession(taskRunId: string): Promise { + // Abort any outstanding SSE subscription for this run first — per + // async-cleanup-ordering guidance, we release external resources + // before awaiting anything that depends on the session being gone. + this.localCommandReceiver.unsubscribe(taskRunId); + const session = this.sessions.get(taskRunId); if (session) { this.cancelInFlightMcpToolCalls(session); @@ -1484,6 +1605,7 @@ For git operations while detached: effort: "effort" in params ? params.effort : undefined, model: "model" in params ? params.model : undefined, jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, + runMode: "runMode" in params ? params.runMode : undefined, }; } diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 98c20a8ce..835845332 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -105,6 +105,26 @@ export const agentRouter = router({ } }), + // Permission resolved subscription - yields when a pending permission gets + // answered through a path other than the local UI (e.g. a mobile client). + // The renderer uses this to clear its mirror of pendingPermissions. + onPermissionResolved: publicProcedure + .input(subscribeSessionInput) + .subscription(async function* (opts) { + const service = getService(); + const targetTaskRunId = opts.input.taskRunId; + const iterable = service.toIterable( + AgentServiceEvent.PermissionResolved, + { signal: opts.signal }, + ); + + for await (const event of iterable) { + if (event.taskRunId === targetTaskRunId) { + yield event; + } + } + }), + // Respond to a permission request from the UI respondToPermission: publicProcedure .input(respondToPermissionInput) diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index a47d7ee56..e7d235e8a 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -16,6 +16,9 @@ const mockTrpcAgent = vi.hoisted(() => ({ cancelPermission: { mutate: vi.fn() }, onSessionEvent: { subscribe: vi.fn() }, onPermissionRequest: { subscribe: vi.fn() }, + onPermissionResolved: { + subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })), + }, onSessionIdleKilled: { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) }, resetAll: { mutate: vi.fn().mockResolvedValue(undefined) }, })); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 75d319dd3..dc5b8e5a6 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -146,6 +146,7 @@ export class SessionService { { event: { unsubscribe: () => void }; permission?: { unsubscribe: () => void }; + permissionResolved?: { unsubscribe: () => void }; } >(); /** Active cloud task watchers, keyed by taskId */ @@ -442,6 +443,7 @@ export class SessionService { adapter: resolvedAdapter, permissionMode: persistedMode, customInstructions: customInstructions || undefined, + runMode: "local", }); if (result) { @@ -616,6 +618,7 @@ export class SessionService { ? (reasoningLevel as EffortLevel) : undefined, model: preferredModel, + runMode: "local", }); const session = this.createBaseSession(taskRun.id, taskId, taskTitle); @@ -725,9 +728,32 @@ export class SessionService { }, ); + // Clears the local pendingPermissions mirror when a permission is + // resolved outside the Electron UI (e.g. a mobile client answers + // the question). Without this, the desktop card would stay visible + // indefinitely even though the agent has already moved on. + const permissionResolvedSubscription = + trpcClient.agent.onPermissionResolved.subscribe( + { taskRunId }, + { + onData: (payload) => { + const session = sessionStoreSetters.getSessions()[taskRunId]; + if (!session) return; + this.resolvePermission(session, payload.toolCallId); + }, + onError: (err) => { + log.error("Permission-resolved subscription error", { + taskRunId, + error: err, + }); + }, + }, + ); + this.subscriptions.set(taskRunId, { event: eventSubscription, permission: permissionSubscription, + permissionResolved: permissionResolvedSubscription, }); } @@ -735,6 +761,7 @@ export class SessionService { const subscription = this.subscriptions.get(taskRunId); subscription?.event.unsubscribe(); subscription?.permission?.unsubscribe(); + subscription?.permissionResolved?.unsubscribe(); this.subscriptions.delete(taskRunId); } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index cb58a4bfc..beb71e880 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -1,6 +1,6 @@ { "expo": { - "name": "PostHog AI", + "name": "PostHog Code", "slug": "posthog", "version": "1.0.0", "orientation": "portrait", @@ -16,9 +16,10 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.posthog.mobile", + "bundleIdentifier": "com.posthog.code", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", + "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", "ITSAppUsesNonExemptEncryption": false } }, @@ -152,7 +153,14 @@ } } ], - "expo-localization" + "expo-localization", + [ + "expo-speech-recognition", + { + "microphonePermission": "Allow PostHog to use your microphone for voice-to-text input", + "speechRecognitionPermission": "Allow PostHog to transcribe your voice input on-device" + } + ] ], "extra": { "router": {}, diff --git a/apps/mobile/assets/sounds/meep.mp3 b/apps/mobile/assets/sounds/meep.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fd7b4cf7e0afaf5cd9ffb0f03afff9c5adee6ace GIT binary patch literal 7941 zcmchccTiN%p2hDBL(W4E;*bXfBn&yozyL#(EK$ih3kZlZgJcN<3@{|gISGP*h?0|_ z@WUi$nk>3!G z{7HPK5d20ASnszGWI33P_x#*Sou$7UT(YLAPCSM~TPEm#8x}Aoosh&E;BMICRB)nB zd{|Fzq3kS0Xg0whU5dPkbXvw%eNTaO0z4&YqJj$?HI(mm(Q$EZBC-F5{pnQr0QRtV zWlzCHTSjkKul9MbcrNS#Y*z)R%tP}#oGg>h2u}S+3E_`46UKtfCzo7mD8;TTwISzB&e+Oj~8oJ zJK!4SGVCR&i23v>j2OzhUJMQyqk?%g78QMMGt>1cTqla$mJs$;Y^hC8d6>|{`if)M zc-v4p^u|@ycBXIOsntc%b<)&DhJ+m46x!}&X^`Efhi`?2M=UC5AtNjq}A&nT_jSGb`G(YL?)5uv+Zucxx{QlHp#>d31Ff=>*CS&E>Ys%@l0Vz$^ zqHBTzu{@KN@!4a|44N<6o|o_6lBGz^W_yl@Xt#MAvKnqxF?;Gg_F=Z&6m!O-dnbJ! z8oe&@Sd&rUcu{cBum*wv&QbVNx=5NE8Zp<5W>wV70%K7lLt~4Q${hF7Du55Oai|7O$bWZNS)*RIz^s`WGSQ!vpZ(8myPS+>!WYqZ}}r0D|bz*#>gV zjw0s_$Fc8)i7_%sH0+E}9+_+ipKA6iRIy4NZYPnK)znBpG-jEX=USzc>l!BJi&)fL zI<0GXaZBkd&qJZ%$)vpkabh+i3^7R(w_y+S+ZfYX1O1?HQ%LF+{G8#Z%H7IE!0E2b z-RD!Uo~`pVF@L!Zs%>k%1mPy z4(g(!j)iDpI1~9K`8&x)S~sKNJ;=${PU$mv(_>~LKObPt>X7O=u4|NmqFY?WI^&a? zWp=jG`RMP1Z_j@*`J9Ree1!BzVd=hJ6eA@Z!65N2+UkH_Ok7A4KVB~Gi*&N*qpOD~ zmhcMpmF9m21pGya=|WN5$Nt*Lfgu0R^lOVhQVb*V(b??NA;PkPX=2F11Tr$aM}>zz z#@!rdISFMK=vzVISRi5|qG?17vqBP=o<_}tG9(OvVq>fuQ zMh|nGT~w)Jjh?r^l!*;8Jyn^0RrXzxG2KQl{<(Ku8XR7Jxr;x!s8#L%1-HXOIK#c3 zc&dqnMig2N&3eBtU!$WD40aXA5WBuLH z(}j#)v!DCS7Hy{vBoSWuMj}aiwkR-E*2*tyMqH#6$@B5O_0yvG*NthzHH5xlQ<``K zlnypN3V4)HYGPnCSXRsb>-7R1j$R;Hl7=}VyXoCik~n`>2e-GSqifdW8}9mhY56hh zq3)#)#~<4Vto@E(O$<|Lz32X10SW#3w5r62k41V}!j+|GAi?fdSr#AJ!vc|=iSS{O zdjh9y986f+*`Gc3yt*c(IQs!}J66Tl#tsx7a8>}V3c&u%`hQn{5n@qR6rm|QkGv3c z-dgSqu^-~5i0UJgPi8)b9vkQPeUM$DDHV#kL&F^s(_`jqf z=2pVP8^wWbd5UjxUv+a7%=)EtHA@-->7D#YKwtz*T`T)E%j{Bt%6_lE(A@Qan&><2 zJ@k2$;@l`(E@Uv9=E8II(00Wgj>fnmreUJPZO(;3qNR3`9_G41nc~qFx zXKfkQU-;z?8RJ?IGY#WAJQ58O^&3|%FVD+}1gLXd$LRnVCp!*nsMO0-dDK7J55T+p zsJc|qL2BZAG4Jw3U_BucG#thgRp$(ubs7fTBQ7Mu%<6L=UooFy=NEV-P}^X3^g)70Ie%T) z-4poQ@f-uEhcnJTN~=DyZ@vITQ?ALvVW$Z z(GjLW#d5z+wGx`p?9I3NU*z*QV1E&s6H*kbyXU|FsVG}pQI-QRM51VW`wHg9vyYZZ zg+=tjQh68Th3TX~yJ?xdoO7Q1J+^G0tHZAz9uk4?3NAmrn)kwrmxipv;8V>V5V@9f zD)EU}iZKkuDNQ}=XI#JB2h{FLfg{{bUMIN5JbnFZoz!hBd=jMabyhVF)F2;`PjW)Fgv?>XYdTYqHrIgE z^|t{YY5mj{5to;`LKAgXL?nDMFdVw4gm2|DdH|M40U?WIU|_f_1mf#XK@BD8e0&x+ zp)DZsc2;ikPRF!Vcxhu)5Hr^l?=_3H9v;b-c`{hX`O}ap79SlMI(Czx%#CGThX5B! z7X<1}?WC88=h<}sDLO2?;E9DR_iLBi(ND#D9PeGXU!JZz$)Mz^*NmfwAuP|`7w+V^ zY8z3dgKKYKH;Lc_oP~Nsc~^!5&bX1cZ}Bbb?%Y?j7DVuQTiuR1Nf4U5y!^T6Kzz@Q z=>d71P-USVh#LSf|DmV92xZDEN{emn_YzgOu&oHR0iZwM*AWyWymVAK*f_2 z*y~mavOhGZP>fFZDN|1udp};T%d~8j-Muv-a`s!(L0OjhdXnrbscPNa{^Zfv7v(Qs zWC|xxoG{%S-a*(a8a{nMJ0+q%n%QLaGM1In$ibU$w#?2WbG%4aU+CTL?Ip%C>k+I@ zP+%d3u}^Y~tjEMVxEF-6B(Qcz!MMxxK7MDto>;tX3ay@VS-!baDY-Jb0YSM-#u1OQ zd{LTw1Qe_q*}f#xd~6)M;4LA?^^k*@&bs;gN50@7Ny$3%LAX!vxBMB|>*0e3*H%Ve zt+j4%x`Yzu>xS>TY}0NABR#@qbaUV4{yb4nlYM^+U!EzCXBc8z_2=5}IO3(C7Ms*o zotyyCont-VQz0u~Y;z4KKbpCA!&SpBI}jI@6}JU>EFQ;=hc>r`{cE%PJD~}A#M?iF zkb-61`_6Je7eMOcK1C0TSu{O1V-d%kK_x5$U2KvpH>{dHIf9BhZT5GRYim+y^(!O0 z$OSfJodiL5L#u8}fbV|GbQm!|uhub2hqaB4y7z5vgPhp%E^?%8pAka)i*9Xvrf_*P9x^t#8p<7Qe-?M$Rfp^?p6#jw{MJo(|gH#&61| zbE_F-1iCfRJ8t!;w^{6K^4j6jxnolfVW#w1XRzpHzW)2FDw~UYG$N9KskF>PrFcUGUaw zdqLX)ig55s;|9$p1$gKy--MTP%>AtY^rN+yISknXix2FSAeLqX{+<-(h83k~p6T@* z36>*Ql-vH~c2Is{4nIJHdD~Mc*%>elEnMfGiQylz6$!dqa-7nHn0O)SD^nE>{joH@ zLx*UUFB&p{o_aW+FbazlODoe$0n_xau?wq53huvK$@+X^BgePPo*k4W`#7B5*t>gL zoz++a(r~voJ(w;ZeG?6j6uKG~M)2h57|eYu2r?d7&6Rh4s4NY6toVcB7mlnPrkO+FBrAQv2e&!c7;h?(N$O>L$#dS* zXA_`OqA(|8L{;bXmr4!viTJ_Nt3D%QZEj|g<$Qb4`B?(iW$)`0_%=`OJ-Yq+zD3Sw zBZ+oysoPV1+C?KleYjN(6VZOz#|dREnlx#u_oEE&;3TJgfR_f37lYKC@@Q=(n>#0k z+Vi_pm+Nq!S+mXuFAdQ*mBPF(ZvKr56~G4r=>Ey&02XRRS+P$uy#j(RW-DtU0Jka# zIC(ry4Cv3->YhN99m-*HLm`g%YFl}!q+wd5K8=RO{IoP3|A#2^GH`H zRxcwVQ~h0If=lO?-+Wi$>NIrGY5d6t6f%Knrcq^9=CmTWbhLq2@nfpQHU-CAjaUMNC)%!44+hzN8_!!3J@3@I^@8b||cu8A2wq;oxM|l7(c*G!P>R zd&B}m$nq>^AGn=4F_(4vplELQd#LZO&ByDDakui*)H7V%xGDow&rOu*h`nbLH*^&m z?B;^cRo-jdlYTQj-RNrNuT?O(qSIHJ)KK$+k-5LT-Ubq_R9JywOH z^B9(kye^+zPMQ&kb`0er2$4AnBmk5SAp1*0K$gBdqJC$;=U0Wm!oO<>Lo7PSubwnN zqnmIrK}J&;u*9072Hm~Tozzj3Y4Sh*!jW}A35qE=@QI8g%gFMfan`!>`PTxlJ1$wi zn2&m^H2bRG+^-7=y0x7QuidZR!$eI;XxEs;-M@@!d+}@L_b2Vo+DC|>VMn}btGuJL&UkpF)Y%Gi;N>mjG!PR_{Dn&?NeIo&`U^ZqN3_h#0fYGx=5}S5f@ihTnFcmc2WUH*UK;W|4R&;3A*2l_!eI`1OF1-On&B+9Z&T z`Kp*gjMko8K$2+)%j%fVl~1hK=oZmQCdPQF%P3ELXCb-fzE_XR}hkTIPFtZ|+A*xQDVp1=jsZt)Tt3#o`Q~%g(nQ z3XjDy+q!9d8Db!Q%rnrokpu9g73+fnwuV;&fl>%@dkHWHUpd63><^aj2Y}sYc4P98=(9e zaAwh}f8T%p;dFmX3P7fiygbd$elK~MOUC{m4gE=bkID%lt8~GWa3nK8u@kl z7p|H6VJ#m;e3`NLu7^lGGg=cRE=VHdLQFHPrVQqVs>;Sk5pv=1K88KJ!$e*_KR;G4 z+(nw|Q~n&mZ-i$POR2Kgy;Pt7K1e)}o8cd0J3$ksg}cH^d?NT8wVzg)3#}DB&S2QJ zjgWXgU9Vei(44DAwxguA&RK7#F?8eK#RK*A^M8+j*{(mUnr1d1Guz^Ct+f z+W685f_b>9AbV8HUpNAWad5^{^O>LM`|NII)*J-qZ)fJ_=*~Cp^Oh`429s2Jpd#~js;?^K6^a!sQ|#oAEr?@#{dOn0 zFG|zTz-+)n>0ZUIP~N=7o%Z=DObsTs04UUN+}>>Z$&Yl?;u4(vfx?00V0{qAk~T!5 zvr@}8Rq`Va!fz2DH!}A#EYo7 zGFM;+0P?1sO9V0+E*sr+%J|JHplg02n&wTF+wUz8noHh&#joG5vd_dIauxBiQs4^} zSWFx56X{6NfQf#QLZdT3RaksS`GFb{pWM+8ipz?I6s>r(&R24a3swXE6ld=Sq_WyE z&270RwimMWx*@4D2W*FkD87u8C8=zmEz}VxyNCXPl<{U;KoQ?&c0o*9Ph(N4Ommv6 z;cViWRWd8~%I~>~>guD}1me3aw@(}Y{5_D!)Y-!Hc!77v>H`V(tNe8YW?h0gu^1st zSGeJ!%blO6wfl( zKS4bEva@@Wy3W6Hq_>k+EA!2>=3+hBYJnl)O#>@Ol#dvkyx-BjXFIOEvhnq$)?d@KVCc&BnT7xZ2r|SCFuF)En~fVZ^<8;I4&MG@ zetYA*_ubkcNd2mGI-?K+5-HAFqWU8?pPM#_Z&IN?-o<&cdbsrSh6o$-!CUb*#!z0g zxU*?ftXk<3a{G%y>!a$T`vSz_A@@IhX#Pn$zM#G_x9WithDGtNtEc-nwnqkD1?-q1 zFg4o#c&hlrs#g6wqq=vw#`X~b?- zBw??s--FEN95)>br7cJy;^O#CVTRXc0%CMWyBziCvDQf*ci`0*Vsja;s4^3U`ei>x7r2nc3tvgBaCz-QQT2LW_b(Ro8W zbYq!t7>)@-g0&M@f`NLm%nBQ#TgG08#2-NK0F-2^=tducjUbd{j46ko#vWIYc_E3W zsr*X3x+>iUo~++&%5*x&@(NTX<3CYj=4SV1kvcoIa`BG7=Fa7g@;cWx3^-Ka`{e0; zMPi=!cvmzi@s1|HZ%F{ZwNBs5r!}dwDn2gV;&Iu2WvdxiE`eY2%4P8F)S~x*z zKtU8ilP}l0_^VMcpw{_Af1NE}$z262%t2DVw_gAA8Z?UCvpNeXNrJ_9*9T zsVEBv0au64iq=vx*!n;n`|R`Cgh#Ac?ijFC2tDB$A&j2Z0GaM*HaL*O8FBRor9`Yd zUf!i9pc~L2yu3>889k+&CI3A4wqES#Yz}lOC!6-46B)FM0@^oaWh~O1VG8=GoOct^ z_i_BD&;ASOJMrQqVR6h}xxYuzll+Br#thNgwFo;bWM;2bniOS3ysoI7W7=6 zSBvL%o-KGt*@#p7pgZ8}C|g_t(Y69hwBohpzdl4YL%v1S0vE4%thr#OicV^SsL68|J1s2NeNB);5KXl$0Dib|fwxlEgt!TyFoNj#ZC7yUI# z^2s_I_#!n!4-{XkoP3>(1C^IWHuhf7pT=Bm@nbTAI)tDt}RGgqYvP`<< zfdo&{OgYsM&2WE`>dZO`31w^uIF~c35X{&Y==8%v2U^0HZbyf~Nnh1QeNIDV@Y_Rj z=_l}>=5^5?^I2Czhxv38tNDug$+d>HQJ>zT(&wqt98~#|{@kkY0^Qo|s00Cw(ZI#l z5Jt_aCWneKYYz{rG1`iBQzbK9`b-TbyN{OLB#oJ zFOQqj(5dq}xs|o{VUT!II?$2T8-Ae2W2DX4BTA(-S}M($dfT>&@pH6f24g%a_N$>7 zT=g<7$&&~7G|EjyoR^M8!J^8cf)dBjnQd!O=fDHy#(kadW&_9PxoZ%@$kGtSBT6Mm zBvu0y$4o|(7jZelhuY9l>K&DvN`gP5<>t7&9IjwDa^bSy%5(3>hh{as{m==O{y$o| M|D)yo|1#*`03iv8i~s-t literal 0 HcmV?d00001 diff --git a/apps/mobile/index.js b/apps/mobile/index.js new file mode 100644 index 000000000..80d3d998f --- /dev/null +++ b/apps/mobile/index.js @@ -0,0 +1 @@ +import "expo-router/entry"; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2652f9df1..2475feace 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,7 +1,7 @@ { "name": "@posthog/mobile", "version": "1.0.0", - "main": "expo-router/entry", + "main": "./index.js", "scripts": { "start": "expo start", "start:clear": "expo start --clear", @@ -24,12 +24,14 @@ "dependencies": { "@expo/ui": "0.2.0-beta.9", "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.90.12", "date-fns": "^4.1.0", "expo": "~54.0.27", "expo-application": "~7.0.8", "expo-auth-session": "^7.0.10", "expo-av": "~16.0.8", + "expo-clipboard": "^55.0.13", "expo-constants": "~18.0.11", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -37,15 +39,18 @@ "expo-file-system": "~19.0.21", "expo-font": "^14.0.10", "expo-glass-effect": "~0.1.8", + "expo-haptics": "^55.0.14", "expo-linear-gradient": "^15.0.8", "expo-linking": "~8.0.10", "expo-localization": "~17.0.8", "expo-router": "~6.0.17", "expo-secure-store": "^15.0.8", + "expo-speech-recognition": "^3.1.2", "expo-splash-screen": "~31.0.12", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-web-browser": "^15.0.10", + "highlight.js": "^11.11.1", "nativewind": "^4.2.1", "phosphor-react-native": "^3.0.2", "posthog-react-native": "^4.18.0", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 9641fa233..1e4bc6033 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,9 +1,11 @@ import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; import { DynamicColorIOS, Platform } from "react-native"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { useThemeColors } from "@/lib/theme"; export default function TabsLayout() { const themeColors = useThemeColors(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); // Dynamic colors for liquid glass effect on iOS const dynamicTextColor = @@ -30,21 +32,23 @@ export default function TabsLayout() { tintColor={dynamicTintColor} minimizeBehavior="onScrollDown" > - {/* Conversations - First Tab (default landing) */} - - - - + {/* Conversations - Chats tab, hidden by default to focus on Code */} + {aiChatEnabled && ( + + + + + )} - {/* Tasks Tab */} + {/* Code tab (task list for PostHog Code) */} - + s.aiChatEnabled); + + if (!aiChatEnabled) { + return ; + } const handleConversationPress = (conversation: ConversationDetail) => { router.push(`/chat/${conversation.id}`); diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 5c3907b3f..df35d9eb4 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -2,15 +2,21 @@ import { router } from "expo-router"; import { Linking, ScrollView, + Switch, Text, TouchableOpacity, View, } from "react-native"; import { useAuthStore, useUserQuery } from "@/features/auth"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; export default function SettingsScreen() { const { logout, cloudRegion, getCloudUrlFromRegion } = useAuthStore(); const { data: userData } = useUserQuery(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); + const setAiChatEnabled = usePreferencesStore((s) => s.setAiChatEnabled); + const pingsEnabled = usePreferencesStore((s) => s.pingsEnabled); + const setPingsEnabled = usePreferencesStore((s) => s.setPingsEnabled); const handleLogout = async () => { await logout(); @@ -88,6 +94,36 @@ export default function SettingsScreen() { + {/* Labs */} + + Labs + + Experimental features + + + + + PostHog AI chat + + + Show the Chats tab for PostHog AI conversations + + + + + + + + Enable pings + + + Play a sound when a task completes + + + + + + {/* All Settings Button */} { + const handle = InteractionManager.runAfterInteractions(() => { + readyRef.current = true; + }); + return () => { + readyRef.current = false; + handle.cancel(); + }; + }, []), + ); const handleCreateTask = () => { router.push("/task"); }; - const handleTaskPress = (taskId: string) => { - router.push(`/task/${taskId}`); - }; + const handleTaskPress = useCallback( + (taskId: string) => { + if (!readyRef.current) return; + readyRef.current = false; + router.push(`/task/${taskId}`); + }, + [router], + ); return ( @@ -20,8 +43,10 @@ export default function TasksScreen() { - Tasks - Your PostHog tasks + Code + + Your PostHog Code sessions + s.aiChatEnabled); const themeColors = useThemeColors(); useScreenTracking(); @@ -47,25 +50,29 @@ function RootLayoutNav() { - {/* Chat routes - regular stack navigation */} - - + {/* Chat routes - only registered when AI chat feature is enabled */} + {aiChatEnabled && ( + <> + + + + )} {/* Task routes - modal presentation */} + diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index b5c8f9838..e0678cfdf 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -1,6 +1,13 @@ import { router } from "expo-router"; import { useMemo, useState } from "react"; -import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native"; +import { + ActivityIndicator, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { type CloudRegion, useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; @@ -28,8 +35,31 @@ export default function AuthScreen() { const [selectedRegion, setSelectedRegion] = useState("us"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [devToken, setDevToken] = useState(""); + const [devProjectId, setDevProjectId] = useState(""); - const { loginWithOAuth } = useAuthStore(); + const { loginWithOAuth, loginWithPersonalApiKey } = useAuthStore(); + + const handleDevSignIn = async () => { + setIsLoading(true); + setError(null); + try { + const projectIdNum = Number(devProjectId); + if (!Number.isFinite(projectIdNum) || projectIdNum <= 0) { + throw new Error("Project ID must be a positive number"); + } + await loginWithPersonalApiKey({ + token: devToken, + projectId: projectIdNum, + region: selectedRegion, + }); + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to sign in"); + } finally { + setIsLoading(false); + } + }; const handleSignIn = async () => { setIsLoading(true); @@ -57,7 +87,11 @@ export default function AuthScreen() { return ( - + {/* Header */} @@ -131,8 +165,52 @@ export default function AuthScreen() { )} + + {__DEV__ && ( + + + Dev sign-in (personal API key) + + + Skips OAuth. Create a personal API key at Settings → User API + keys with scopes: user:read, project:read, task:write, + integration:read, conversation:write, query:read. + + + + + + Dev sign in + + + + )} - + ); } diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 35a1aa2d7..814c9b029 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,13 +1,15 @@ import { Text } from "@components/text"; +import * as Haptics from "expo-haptics"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Pressable, View } from "react-native"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Composer } from "@/features/chat"; import { getTask, + runTaskInCloud, type Task, TaskSessionView, useTaskSessionStore, @@ -22,9 +24,15 @@ export default function TaskDetailScreen() { const [task, setTask] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(false); - const { connectToTask, disconnectFromTask, sendPrompt, getSessionForTask } = - useTaskSessionStore(); + const { + connectToTask, + disconnectFromTask, + sendPrompt, + sendPermissionResponse, + getSessionForTask, + } = useTaskSessionStore(); const session = taskId ? getSessionForTask(taskId) : undefined; @@ -47,66 +55,144 @@ export default function TaskDetailScreen() { useEffect(() => { if (!taskId) return; + let cancelled = false; setLoading(true); setError(null); getTask(taskId) .then((fetchedTask) => { + if (cancelled) return; setTask(fetchedTask); return connectToTask(fetchedTask); }) + .then(() => { + if (cancelled) return; + // Brief delay for FlatList to render its initial batch behind + // the loading overlay before revealing. + setTimeout(() => setLoading(false), 150); + }) .catch((err) => { + if (cancelled) return; console.error("Failed to load task:", err); setError("Failed to load task"); - }) - .finally(() => { setLoading(false); }); return () => { + cancelled = true; disconnectFromTask(taskId); }; }, [taskId, connectToTask, disconnectFromTask]); + // Refresh task title periodically — the server often generates a better + // title after the agent starts working. + useEffect(() => { + if (!taskId || loading) return; + const interval = setInterval(() => { + getTask(taskId) + .then((fresh) => { + if (fresh.title && fresh.title !== task?.title) { + setTask((prev) => (prev ? { ...prev, title: fresh.title } : prev)); + } + }) + .catch(() => {}); + }, 10_000); + return () => clearInterval(interval); + }, [taskId, loading, task?.title]); + + // Auto-reconnect if the session disappears while the screen is active + // (e.g., cloud sandbox expired and the session was cleaned up). + // Re-fetches the task to get a fresh S3 presigned URL. + useEffect(() => { + if (!taskId || !task || loading) return; + if (session) return; + if (retrying) return; + + let cancelled = false; + getTask(taskId) + .then((freshTask) => { + if (cancelled) return; + setTask(freshTask); + return connectToTask(freshTask); + }) + .catch((err) => { + if (cancelled) return; + console.error("Failed to reconnect to task:", err); + }); + + return () => { + cancelled = true; + }; + }, [taskId, task, loading, session, connectToTask, retrying]); + const handleSendPrompt = useCallback( (text: string) => { if (!taskId) return; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); sendPrompt(taskId, text).catch((err) => { console.error("Failed to send prompt:", err); + Alert.alert( + "Failed to send", + "Your message could not be delivered. Please try again.", + ); }); }, [taskId, sendPrompt], ); + const handleRetry = useCallback(async () => { + if (!taskId || !task) return; + try { + setRetrying(true); + disconnectFromTask(taskId); + + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + }); + setTask(updatedTask); + await connectToTask(updatedTask); + // Don't clear retrying here — the effect below clears it + // once the session shows meaningful state (thinking or terminal). + } catch (err) { + console.error("Failed to retry task:", err); + setRetrying(false); + Alert.alert( + "Retry failed", + "Could not restart the task. Please try again.", + ); + } + }, [taskId, task, disconnectFromTask, connectToTask]); + + // Clear retrying once the agent finishes a turn or the run terminates. + useEffect(() => { + if (!retrying || !session) return; + if (!session.isPromptPending || session.terminalStatus) { + setRetrying(false); + } + }, [retrying, session]); + + const handleSendPermissionResponse = useCallback( + (args: Parameters[1]) => { + if (!taskId) return; + sendPermissionResponse(taskId, args).catch((err) => { + console.error("Failed to send permission response:", err); + Alert.alert( + "Failed to respond", + "Your permission response could not be sent. Please try again.", + ); + }); + }, + [taskId, sendPermissionResponse], + ); + const handleOpenTask = useCallback( (newTaskId: string) => { - router.push(`/task/${newTaskId}`); + router.replace(`/task/${newTaskId}`); }, [router], ); - if (loading) { - return ( - <> - - - - Loading task... - - - ); - } - - if (error || !task) { + if (error || (!task && !loading)) { return ( <> { + if (e.type !== "session_update") return false; + const su = (e.notification as Record)?.update; + return visibleAgentTypes.includes( + (su as Record)?.sessionUpdate as string, + ); + }) ?? false; + return ( <> ( + + + {environment === "cloud" ? "Cloud" : "Local"} + + + ) + : undefined, }} /> + {/* Always render TaskSessionView so the FlatList can layout behind + the loading overlay. This prevents the "flash of messages" when + switching from loading spinner to rendered content. */} - {/* Fixed input at bottom */} - - - + {/* Loading overlay — covers the list while it does initial layout */} + {loading && ( + + + + {task?.latest_run ? "Connecting..." : "Loading task..."} + + + )} + + {/* Fixed input at bottom — hidden when run is terminal */} + {!session?.terminalStatus && ( + + + + )} ); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 42e535e15..1f4aaf2db 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; import { Stack, useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -79,10 +79,17 @@ export default function NewTaskScreen() { const [integrations, setIntegrations] = useState([]); const [repositories, setRepositories] = useState([]); const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearch, setRepoSearch] = useState(""); const [prompt, setPrompt] = useState(""); const [creating, setCreating] = useState(false); const [loadingRepos, setLoadingRepos] = useState(true); + const filteredRepositories = useMemo(() => { + const query = repoSearch.trim().toLowerCase(); + if (!query) return repositories; + return repositories.filter((repo) => repo.toLowerCase().includes(query)); + }, [repositories, repoSearch]); + const loadIntegrations = useCallback(async () => { try { setLoadingRepos(true); @@ -116,14 +123,21 @@ export default function NewTaskScreen() { try { const githubIntegration = integrations.find((i) => i.kind === "github"); + const trimmedPrompt = prompt.trim(); const task = await createTask({ - description: prompt.trim(), - title: prompt.trim().slice(0, 100), + description: trimmedPrompt, + title: trimmedPrompt.slice(0, 100), repository: selectedRepo, github_integration: githubIntegration?.id, }); - await runTaskInCloud(task.id); + // Pass the prompt as pending_user_message so the cloud agent has + // something to process on start — matches how the desktop launches + // new cloud runs. Without this the sandbox starts idle and the UI + // stays stuck on "Thinking...". + await runTaskInCloud(task.id, { + pendingUserMessage: trimmedPrompt, + }); // Navigate to task detail (replaces current modal) router.replace(`/task/${task.id}`); @@ -161,29 +175,50 @@ export default function NewTaskScreen() { ) : ( <> Repository + - item} - renderItem={({ item }) => ( - setSelectedRepo(item)} - className={`border-gray-6 border-b px-3 py-3 ${ - selectedRepo === item ? "bg-accent-3" : "" - }`} - > - + + {repoSearch + ? `No repositories match "${repoSearch}"` + : "No repositories available"} + + + ) : ( + item} + keyboardShouldPersistTaps="handled" + renderItem={({ item }) => ( + setSelectedRepo(item)} + className={`border-gray-6 border-b px-3 py-3 ${ + selectedRepo === item ? "bg-accent-3" : "" }`} > - {item} - - - )} - /> + + {item} + + + )} + /> + )} Task description diff --git a/apps/mobile/src/components/OfflineBanner.tsx b/apps/mobile/src/components/OfflineBanner.tsx new file mode 100644 index 000000000..e70814fa0 --- /dev/null +++ b/apps/mobile/src/components/OfflineBanner.tsx @@ -0,0 +1,23 @@ +import { WifiSlash } from "phosphor-react-native"; +import { Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; + +export function OfflineBanner() { + const { isConnected } = useNetworkStatus(); + const insets = useSafeAreaInsets(); + + if (isConnected) return null; + + return ( + + + + No internet connection + + + ); +} diff --git a/apps/mobile/src/features/auth/stores/authStore.ts b/apps/mobile/src/features/auth/stores/authStore.ts index a9d105dbe..79a029415 100644 --- a/apps/mobile/src/features/auth/stores/authStore.ts +++ b/apps/mobile/src/features/auth/stores/authStore.ts @@ -29,6 +29,11 @@ interface AuthState { // Methods loginWithOAuth: (region: CloudRegion) => Promise; + loginWithPersonalApiKey: (params: { + token: string; + projectId: number; + region: CloudRegion; + }) => Promise; refreshAccessToken: () => Promise; scheduleTokenRefresh: () => void; initializeAuth: () => Promise; @@ -96,6 +101,40 @@ export const useAuthStore = create()( get().scheduleTokenRefresh(); }, + loginWithPersonalApiKey: async ({ token, projectId, region }) => { + if (!__DEV__) { + throw new Error( + "Dev sign-in is only available in development builds", + ); + } + const trimmed = token.trim(); + if (!trimmed) { + throw new Error("Personal API key is required"); + } + if (!Number.isFinite(projectId) || projectId <= 0) { + throw new Error("Valid project ID is required"); + } + + const storedTokens: StoredTokens = { + accessToken: trimmed, + refreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudRegion: region, + scopedTeams: [projectId], + }; + + await saveTokens(storedTokens); + + set({ + oauthAccessToken: trimmed, + oauthRefreshToken: null, + tokenExpiry: null, + cloudRegion: region, + projectId, + isAuthenticated: true, + }); + }, + refreshAccessToken: async () => { const state = get(); @@ -140,7 +179,8 @@ export const useAuthStore = create()( refreshTimeoutId = null; } - if (!state.tokenExpiry) { + // Personal API key sessions have no refresh token — nothing to schedule. + if (!state.tokenExpiry || !state.oauthRefreshToken) { return; } diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx index 96f3f1bc8..1c4ee79ba 100644 --- a/apps/mobile/src/features/chat/components/AgentMessage.tsx +++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx @@ -1,10 +1,13 @@ +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; import { Brain } from "phosphor-react-native"; -import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useState } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { usePeriodicRerender } from "../hooks/usePeriodicRerender"; import type { AssistantToolCall } from "../types"; import { getRandomThinkingMessage } from "../utils/thinkingMessages"; +import { MarkdownText } from "./MarkdownText"; import { ToolMessage } from "./ToolMessage"; interface AgentMessageProps { @@ -14,6 +17,7 @@ interface AgentMessageProps { toolCalls?: AssistantToolCall[]; hasHumanMessageAfter?: boolean; onOpenTask?: (taskId: string) => void; + timestamp?: number; } interface ReasoningBlockProps { @@ -60,6 +64,18 @@ function ReasoningBlock({ content, isComplete }: ReasoningBlockProps) { const THINKING_MESSAGE_INTERVAL_MS = 2000; +function formatRelativeTime(ts: number): string { + const diffMs = Date.now() - ts; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + export function AgentMessage({ content, isLoading, @@ -67,12 +83,21 @@ export function AgentMessage({ toolCalls, hasHumanMessageAfter, onOpenTask, + timestamp, }: AgentMessageProps) { usePeriodicRerender(isLoading ? THINKING_MESSAGE_INTERVAL_MS : 0); const hasContent = !!content; const isComplete = !isLoading && hasContent; + const handleLongPress = useCallback(() => { + if (!content) return; + Clipboard.setStringAsync(content).then(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Copied", "Message copied to clipboard."); + }); + }, [content]); + return ( {toolCalls && toolCalls.length > 0 && ( @@ -104,13 +129,19 @@ export function AgentMessage({ )} - {/* Show final content */} + {/* Show final content — long-press to copy */} {content && ( - - - {content} - - + + + + + + )} + + {timestamp && !isLoading && ( + + {formatRelativeTime(timestamp)} + )} ); diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index d7f5bb9ef..0a1104389 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -1,8 +1,10 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; import { ArrowUp, Microphone, Stop } from "phosphor-react-native"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, + Animated, + Easing, Platform, TextInput, TouchableOpacity, @@ -15,12 +17,76 @@ interface ComposerProps { onSend: (message: string) => void; disabled?: boolean; placeholder?: string; + isUserTurn?: boolean; + queuedCount?: number; +} + +function PulsingBorder({ + active, + color, +}: { + active: boolean; + color: string; +}) { + const opacity = useRef(new Animated.Value(0)).current; + const animRef = useRef(null); + + useEffect(() => { + if (active) { + opacity.setValue(0); + animRef.current = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]), + ); + animRef.current.start(); + } else { + animRef.current?.stop(); + animRef.current = null; + opacity.setValue(0); + } + return () => { + animRef.current?.stop(); + }; + }, [active, opacity]); + + if (!active) return null; + + return ( + + ); } export function Composer({ onSend, disabled = false, placeholder = "Ask a question", + isUserTurn = false, + queuedCount = 0, }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); @@ -55,6 +121,11 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; + const effectivePlaceholder = queuedCount > 0 + ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` + : !isUserTurn && !disabled + ? "Message will be queued..." + : placeholder; if (Platform.OS === "ios") { return ( @@ -85,40 +156,46 @@ export function Composer({ gap: 8, }} > - {/* Input field with rounded glass background */} - - + + - + isInteractive + > + + + {/* Mic / Send button */} { + Clipboard.setStringAsync(content).then(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Copied", "Message copied to clipboard."); + }); + }, [content]); + return ( - - - {content} + + + + {content} + + + + {timestamp && ( + + {formatRelativeTime(timestamp)} - + )} ); } diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx new file mode 100644 index 000000000..1bc39a79d --- /dev/null +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -0,0 +1,372 @@ +import { useMemo } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { getColorForClass, highlightCode } from "@/lib/syntax-highlight"; +import { useThemeColors } from "@/lib/theme"; + +interface MarkdownTextProps { + content: string; +} + +function HighlightedCode({ + code, + language, +}: { + code: string; + language: string; +}) { + const themeColors = useThemeColors(); + const segments = useMemo( + () => highlightCode(code, language), + [code, language], + ); + + if (!segments) { + return ( + + {code} + + ); + } + + return ( + + {segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +} + +interface Block { + type: "paragraph" | "code" | "heading" | "list" | "table"; + content: string; + language?: string; + level?: number; + items?: string[]; + ordered?: boolean; + rows?: string[][]; +} + +function parseBlocks(text: string): Block[] { + const lines = text.split("\n"); + const blocks: Block[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Code block + if (line.startsWith("```")) { + const language = line.slice(3).trim() || undefined; + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith("```")) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + blocks.push({ type: "code", content: codeLines.join("\n"), language }); + continue; + } + + // Heading + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + blocks.push({ + type: "heading", + content: headingMatch[2], + level: headingMatch[1].length, + }); + i++; + continue; + } + + // Unordered list (consecutive - or * lines) + if (/^\s*[-*]\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*[-*]\s/.test(lines[i])) { + items.push(lines[i].replace(/^\s*[-*]\s+/, "")); + i++; + } + blocks.push({ type: "list", content: "", items }); + continue; + } + + // Ordered list (consecutive 1. 2. lines) + if (/^\s*\d+[.)]\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*\d+[.)]\s/.test(lines[i])) { + items.push(lines[i].replace(/^\s*\d+[.)]\s+/, "")); + i++; + } + blocks.push({ type: "list", content: "", items, ordered: true }); + continue; + } + + // Table: lines with pipes, second line is separator (|---|---|) + if ( + line.includes("|") && + i + 1 < lines.length && + /^\s*\|?[\s-:|]+\|/.test(lines[i + 1]) + ) { + const rows: string[][] = []; + while (i < lines.length && lines[i].includes("|")) { + const row = lines[i] + .replace(/^\s*\|/, "") + .replace(/\|\s*$/, "") + .split("|") + .map((cell) => cell.trim()); + // Skip the separator row + if (!/^[\s-:|]+$/.test(lines[i].replace(/\|/g, ""))) { + rows.push(row); + } + i++; + } + if (rows.length > 0) { + blocks.push({ type: "table", content: "", rows }); + } + continue; + } + + // Empty line + if (line.trim() === "") { + i++; + continue; + } + + // Paragraph: collect consecutive non-special lines + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() !== "" && + !lines[i].startsWith("```") && + !lines[i].match(/^#{1,6}\s/) && + !/^\s*[-*]\s/.test(lines[i]) && + !/^\s*\d+[.)]\s/.test(lines[i]) && + !( + lines[i].includes("|") && + i + 1 < lines.length && + /^\s*\|?[\s-:|]+\|/.test(lines[i + 1]) + ) + ) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length > 0) { + blocks.push({ type: "paragraph", content: paraLines.join("\n") }); + } + } + + return blocks; +} + +function renderInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; + let lastIndex = 0; + let match: RegExpExecArray | null = null; + + // biome-ignore lint/suspicious/noAssignInExpressions: regex exec loop + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + if (match[2]) { + nodes.push( + + {match[2]} + , + ); + } else if (match[3]) { + nodes.push( + + {match[3]} + , + ); + } else if (match[4]) { + nodes.push( + + {match[4]} + , + ); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +export function MarkdownText({ content }: MarkdownTextProps) { + const blocks = parseBlocks(content); + + return ( + + {blocks.map((block, i) => { + const key = `block-${i}`; + + switch (block.type) { + case "code": + return ( + + {block.language && ( + + + {block.language} + + + )} + + {block.language ? ( + + ) : ( + + {block.content} + + )} + + + ); + + case "heading": + return ( + + {renderInline(block.content)} + + ); + + case "list": + return ( + + {block.items?.map((item, idx) => ( + + + {block.ordered ? `${idx + 1}.` : "•"} + + + {renderInline(item)} + + + ))} + + ); + + case "table": { + const rows = block.rows ?? []; + const header = rows[0]; + const body = rows.slice(1); + return ( + + + {header && ( + + {header.map((cell, col) => { + const colKey = `${key}-h${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} + + )} + {body.map((row, ri) => { + const rowKey = `${key}-r${ri}`; + return ( + + {row.map((cell, col) => { + const cellKey = `${rowKey}-c${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} + + ); + })} + + + ); + } + + default: + return ( + + {renderInline(block.content)} + + ); + } + })} + + ); +} diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index ee6085fcd..de3417823 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -14,7 +14,7 @@ import { Trash, Wrench, } from "phosphor-react-native"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, Pressable, @@ -22,6 +22,11 @@ import { TouchableOpacity, View, } from "react-native"; +import { + getColorForClass, + highlightCode, + languageFromPath, +} from "@/lib/syntax-highlight"; import { useThemeColors } from "@/lib/theme"; export type ToolStatus = "pending" | "running" | "completed" | "error"; @@ -54,6 +59,81 @@ const kindIcons: Record = { other: Wrench, }; +export function deriveToolKind(toolName: string): ToolKind { + // Agent titles can include file paths, e.g. "Edit `src/foo.ts`" or + // "Read 200 lines in `bar.ts`", so match on prefix / keyword. + const name = toolName.toLowerCase(); + if (name.startsWith("read") || name === "read_file") return "read"; + if ( + name.startsWith("edit") || + name.startsWith("write") || + name.startsWith("multiedit") || + name.startsWith("multi_edit") || + name === "search_replace" + ) + return "edit"; + if (name.startsWith("delete")) return "delete"; + if ( + name.startsWith("grep") || + name.startsWith("search") || + name.startsWith("glob") || + name.startsWith("find") || + name.startsWith("list") + ) + return "search"; + if ( + name.startsWith("bash") || + name.startsWith("execute") || + name.startsWith("terminal") + ) + return "execute"; + if (name.startsWith("think")) return "think"; + if (name.startsWith("webfetch") || name.startsWith("fetch")) return "fetch"; + if (name === "create_task") return "create_task"; + return "other"; +} + +export function getToolSubtitle( + toolName: string, + args?: Record, +): string | null { + if (!args) return null; + const kind = deriveToolKind(toolName); + + switch (kind) { + case "read": + case "edit": + case "delete": + case "move": + if (typeof args.file_path === "string") + return shortenPath(args.file_path); + if (typeof args.target_file === "string") + return shortenPath(args.target_file); + return null; + case "search": + if (typeof args.pattern === "string") return `"${args.pattern}"`; + return null; + case "execute": + if (typeof args.command === "string") + return args.command.length > 60 + ? `${args.command.slice(0, 60)}...` + : args.command; + return null; + case "fetch": + if (typeof args.url === "string") + return args.url.length > 60 ? `${args.url.slice(0, 60)}...` : args.url; + return null; + case "think": + if (typeof args.content === "string") + return args.content.length > 60 + ? `${args.content.slice(0, 60)}...` + : args.content; + return null; + default: + return null; + } +} + interface CreateTaskArgs { title?: string; description?: string; @@ -93,6 +173,456 @@ export function formatToolTitle( return toolName; } +// Shape guards for file-editing tool args. The agent forwards Claude's raw +// tool input through the ACP `rawInput` field, so we can detect Edit / Write / +// MultiEdit by the keys present in args. +interface EditArgs { + file_path: string; + old_string: string; + new_string: string; +} + +interface MultiEditArgs { + file_path: string; + edits: Array<{ old_string: string; new_string: string }>; +} + +interface WriteArgs { + file_path: string; + content: string; +} + +function asEditArgs( + args: Record | undefined, +): EditArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.old_string === "string" && + typeof args.new_string === "string" + ) { + return { + file_path: args.file_path, + old_string: args.old_string, + new_string: args.new_string, + }; + } + return null; +} + +function asMultiEditArgs( + args: Record | undefined, +): MultiEditArgs | null { + if (!args || typeof args.file_path !== "string") return null; + if (!Array.isArray(args.edits)) return null; + const edits: MultiEditArgs["edits"] = []; + for (const raw of args.edits) { + if ( + raw && + typeof raw === "object" && + typeof (raw as Record).old_string === "string" && + typeof (raw as Record).new_string === "string" + ) { + edits.push({ + old_string: (raw as Record).old_string as string, + new_string: (raw as Record).new_string as string, + }); + } + } + if (edits.length === 0) return null; + return { file_path: args.file_path, edits }; +} + +function asWriteArgs( + args: Record | undefined, +): WriteArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.content === "string" && + args.old_string === undefined + ) { + return { file_path: args.file_path, content: args.content }; + } + return null; +} + +// Strip ANSI escape codes from terminal output +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI codes requires matching control chars + return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); +} + +function extractResultText(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + if (typeof obj[key] === "string") return obj[key] as string; + } + } + return null; +} + +function countDiffLines( + editArgs: EditArgs | null, + multiEditArgs: MultiEditArgs | null, + writeArgs: WriteArgs | null, +): { added: number; removed: number } { + let added = 0; + let removed = 0; + + const countFromDiff = (oldText: string, newText: string) => { + const lines = computeLineDiff(oldText, newText, Number.MAX_SAFE_INTEGER); + for (const line of lines) { + if (line.kind === "added") added++; + else if (line.kind === "removed") removed++; + } + }; + + if (editArgs) { + countFromDiff(editArgs.old_string ?? "", editArgs.new_string ?? ""); + } else if (multiEditArgs) { + for (const edit of multiEditArgs.edits) { + countFromDiff(edit.old_string ?? "", edit.new_string ?? ""); + } + } else if (writeArgs) { + added = writeArgs.content ? writeArgs.content.split("\n").length : 0; + } + + return { added, removed }; +} + +// Extract a file path from agent tool titles like "Read `src/foo.ts`" or +// "Read 200 lines in `bar.ts`" when rawInput/args are unavailable. +function extractPathFromTitle(title: string): string | null { + const backtickMatch = title.match(/`([^`]+)`/); + if (backtickMatch) return backtickMatch[1]; + // Fallback: strip common prefixes like "Read file", "Read 200 lines in" + const stripped = title + .replace(/^read\s+/i, "") + .replace(/^file\s*/i, "") + .replace(/^\d+\s+lines?\s+in\s+/i, "") + .trim(); + // Only treat the remainder as a path if it looks like one + if (stripped.includes("/") || stripped.includes(".")) return stripped; + return null; +} + +function shortenPath(path: string, maxLen = 48): string { + if (path.length <= maxLen) return path; + const parts = path.split("/"); + if (parts.length <= 2) return `…${path.slice(-(maxLen - 1))}`; + return `…/${parts.slice(-2).join("/")}`; +} + +// Unified diff support — detects and renders `git diff` output when the agent +// runs commands like `git diff` through the Bash tool and the result comes +// back as stdout rather than a structured tool content block. +type UnifiedDiffLine = + | { kind: "file"; text: string } + | { kind: "hunk"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "context"; text: string } + | { kind: "meta"; text: string }; + +function looksLikeUnifiedDiff(text: string): boolean { + if (!text) return false; + if (/(^|\n)diff --git /.test(text)) return true; + return /(^|\n)--- /.test(text) && /(^|\n)\+\+\+ /.test(text); +} + +function extractDiffFromResult(result: unknown): string | null { + if (typeof result === "string") { + return looksLikeUnifiedDiff(result) ? result : null; + } + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + const value = obj[key]; + if (typeof value === "string" && looksLikeUnifiedDiff(value)) { + return value; + } + } + } + return null; +} + +function parseUnifiedDiff(text: string): UnifiedDiffLine[] { + const result: UnifiedDiffLine[] = []; + for (const line of text.split("\n")) { + if ( + line.startsWith("diff --git ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("index ") || + line.startsWith("new file mode") || + line.startsWith("deleted file mode") || + line.startsWith("similarity index") || + line.startsWith("rename ") + ) { + result.push({ kind: "file", text: line }); + } else if (line.startsWith("@@")) { + result.push({ kind: "hunk", text: line }); + } else if (line.startsWith("+")) { + result.push({ kind: "added", text: line }); + } else if (line.startsWith("-")) { + result.push({ kind: "removed", text: line }); + } else if (line.startsWith(" ")) { + result.push({ kind: "context", text: line }); + } else { + result.push({ kind: "meta", text: line }); + } + } + return result; +} + +interface UnifiedDiffBlockProps { + diffText: string; + maxLines?: number; +} + +function UnifiedDiffBlock({ diffText, maxLines = 120 }: UnifiedDiffBlockProps) { + const allLines = parseUnifiedDiff(diffText); + const truncated = allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + let cls = "font-mono text-[11px] leading-4 text-gray-11 px-2"; + if (line.kind === "file") { + cls += " text-gray-9"; + } else if (line.kind === "hunk") { + cls += " bg-accent-3 text-accent-11"; + } else if (line.kind === "added") { + cls += " bg-status-success/10 text-status-success"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10 text-status-error"; + } else if (line.kind === "context") { + cls += " text-gray-11"; + } else { + cls += " text-gray-9"; + } + return ( + + {line.text || " "} + + ); + })} + {truncated && ( + + … {allLines.length - maxLines} more lines + + )} + + ); +} + +// LCS-based line diff: correctly identifies unchanged lines even when +// changes are scattered throughout the block, then collapses distant +// context into separators. +type DiffLine = + | { kind: "context"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "separator" }; + +// O(n*m) LCS — fine for typical edit blocks (< 200 lines). +function lcsBacktrack(a: string[], b: string[]): DiffLine[] { + const m = a.length; + const n = b.length; + + // Build LCS table + const dp: number[][] = []; + for (let i = 0; i <= m; i++) { + dp[i] = new Array(n + 1).fill(0); + } + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + // Backtrack to produce diff + const result: DiffLine[] = []; + let i = m; + let j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + result.push({ kind: "context", text: a[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.push({ kind: "added", text: b[j - 1] }); + j--; + } else { + result.push({ kind: "removed", text: a[i - 1] }); + i--; + } + } + result.reverse(); + return result; +} + +// Collapse context lines far from changes into separators. +function collapseContext(lines: DiffLine[], contextLines: number): DiffLine[] { + // Mark which lines are near a change + const isChange = lines.map((l) => l.kind === "added" || l.kind === "removed"); + const nearChange = new Array(lines.length).fill(false); + for (let i = 0; i < lines.length; i++) { + if (isChange[i]) { + for ( + let k = Math.max(0, i - contextLines); + k <= Math.min(lines.length - 1, i + contextLines); + k++ + ) { + nearChange[k] = true; + } + } + } + + const result: DiffLine[] = []; + let inSkip = false; + for (let i = 0; i < lines.length; i++) { + if (nearChange[i] || isChange[i]) { + inSkip = false; + result.push(lines[i]); + } else if (!inSkip) { + inSkip = true; + result.push({ kind: "separator" }); + } + } + return result; +} + +function computeLineDiff( + oldText: string, + newText: string, + contextLines = 2, +): DiffLine[] { + const oldLines = oldText.length > 0 ? oldText.split("\n") : []; + const newLines = newText.length > 0 ? newText.split("\n") : []; + + if (oldLines.length === 0) { + return newLines.map((l) => ({ kind: "added" as const, text: l })); + } + if (newLines.length === 0) { + return oldLines.map((l) => ({ kind: "removed" as const, text: l })); + } + + const raw = lcsBacktrack(oldLines, newLines); + return collapseContext(raw, contextLines); +} + +interface DiffBlockProps { + oldText: string; + newText: string; + language?: string | null; + maxLines?: number; +} + +function HighlightedDiffLine({ + text, + language, + fallbackColor, +}: { + text: string; + language?: string | null; + fallbackColor: string; +}) { + const segments = useMemo( + () => (language ? highlightCode(text, language) : null), + [text, language], + ); + + if (!segments) { + return <>{text || " "}; + } + + return ( + <> + {segments.map((seg, i) => { + const color = getColorForClass(seg.className); + return ( + + {seg.text} + + ); + })} + + ); +} + +function DiffBlock({ + oldText, + newText, + language, + maxLines = 60, +}: DiffBlockProps) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + const allLines = computeLineDiff(oldText, newText); + const truncated = !expanded && allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + const key = `${line.kind}-${i}`; + if (line.kind === "separator") { + return ( + + ··· + + ); + } + let cls = "font-mono text-[11px] leading-4 px-2"; + const fallbackColor = + line.kind === "added" + ? themeColors.status.success + : line.kind === "removed" + ? themeColors.status.error + : themeColors.gray[11]; + if (line.kind === "added") { + cls += " bg-status-success/10"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10"; + } + const prefix = + line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "; + return ( + + {prefix} + + + ); + })} + {truncated && ( + setExpanded(true)}> + + Show all {allLines.length} lines + + + )} + + ); +} + function CreateTaskPreview({ args, showAction, @@ -234,13 +764,131 @@ export function ToolMessage({ const isLoading = status === "pending" || status === "running"; const isFailed = status === "error"; - const hasDetails = args || result !== undefined; const displayTitle = formatToolTitle(toolName, args); const KindIcon = kind ? kindIcons[kind] : Wrench; const isCreateTask = toolName.toLowerCase() === "create_task" || kind === "create_task"; + // File-editing tools get a proper diff view using the rawInput we already + // receive on the wire. Detection is by shape, not tool name, so it works + // regardless of how the agent labels the tool. + const editArgs = asEditArgs(args); + const multiEditArgs = !editArgs ? asMultiEditArgs(args) : null; + const writeArgs = !editArgs && !multiEditArgs ? asWriteArgs(args) : null; + const fileToolArgs = editArgs ?? multiEditArgs ?? writeArgs; + + // Unified-diff-in-result: when the agent runs commands like `git diff` + // via the Bash tool, the result comes back as stdout containing a unified + // diff string. Detect that and render it as a real diff view. + const unifiedDiffText = !fileToolArgs ? extractDiffFromResult(result) : null; + + if (fileToolArgs && !isCreateTask) { + const stats = countDiffLines(editArgs, multiEditArgs, writeArgs); + const diffLanguage = languageFromPath(fileToolArgs.file_path); + // Collapse diffs for failed edits (retries make them noise) + const showDiff = !isFailed || isOpen; + + return ( + + {/* Header row */} + isFailed && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!isFailed} + > + {isLoading ? ( + + ) : ( + + )} + + {shortenPath(fileToolArgs.file_path)} + + {stats.added > 0 && !isFailed && ( + + +{stats.added} + + )} + {stats.removed > 0 && !isFailed && ( + + -{stats.removed} + + )} + {diffLanguage && !isFailed && ( + + {diffLanguage} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Diff content — collapsed when failed */} + {showDiff && ( + <> + {editArgs && ( + + )} + {multiEditArgs?.edits.map((edit, i) => ( + + ))} + {writeArgs && ( + + )} + + )} + + ); + } + + // Unified-diff-in-result renderer (e.g. `git diff` via Bash) + if (unifiedDiffText && !isCreateTask) { + return ( + + + {isLoading ? ( + + ) : ( + + )} + + {displayTitle} + + {isFailed && ( + (Failed) + )} + + + + ); + } + // For create_task, show rich preview instead of expandable if (isCreateTask && args) { return ( @@ -264,18 +912,169 @@ export function ToolMessage({ ); } + const resolvedKind = kind ?? deriveToolKind(toolName); + const isPending = status === "pending"; + const isRunning = status === "running"; + const isCompleted = status === "completed"; + const resultText = extractResultText(result); + + // Execute/Bash: show description + command subtitle + expandable output + if (resolvedKind === "execute") { + const command = typeof args?.command === "string" ? args.command : null; + const description = + typeof args?.description === "string" ? args.description : null; + const outputText = resultText ? stripAnsi(resultText) : null; + const hasOutput = outputText && outputText.trim().length > 0; + + return ( + + {/* Header */} + hasOutput && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasOutput} + > + {isLoading ? ( + + ) : ( + + )} + + {description ?? displayTitle} + + {isFailed && ( + + Failed + + )} + + + {/* Command as subtitle line */} + {command && ( + + $ {command} + + )} + + {/* Output */} + {isOpen && hasOutput && ( + + + {outputText} + + + )} + + ); + } + + // Read: show file path, line range, and expandable content preview + if (resolvedKind === "read") { + // Try args first, then extract a path from the tool title (e.g. + // "Read `src/foo.ts`" or "Read 200 lines in `bar.ts`"). + const filePath = + typeof args?.file_path === "string" + ? args.file_path + : typeof args?.target_file === "string" + ? args.target_file + : extractPathFromTitle(toolName); + const hasContent = resultText && resultText.trim().length > 0; + const lineCount = hasContent ? resultText.split("\n").length : null; + const offset = typeof args?.offset === "number" ? args.offset : null; + const limit = typeof args?.limit === "number" ? args.limit : null; + const lineRange = offset + ? `lines ${offset}–${offset + (limit ?? lineCount ?? 0)}` + : lineCount + ? `${lineCount} lines` + : null; + + return ( + + hasContent && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasContent} + > + {isLoading ? ( + + ) : ( + + )} + + Read + + {filePath ? ( + + {shortenPath(filePath, 36)} + + ) : null} + {lineRange && isCompleted && ( + + {lineRange} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Content preview */} + {isOpen && hasContent && ( + + + {resultText} + + + )} + + ); + } + + // Default: all other tools (search, think, fetch, etc.) + const subtitle = getToolSubtitle(toolName, args); + return ( - - hasDetails && setIsOpen(!isOpen)} - className="flex-row items-center gap-2" - disabled={!hasDetails} - > + + {/* Status indicator */} {isLoading ? ( ) : ( - + )} {/* Tool name */} @@ -283,45 +1082,28 @@ export function ToolMessage({ {displayTitle} + {/* Queued label */} + {isPending && ( + Queued + )} + {/* Failed indicator */} {isFailed && ( - (Failed) + + Failed + )} - - - {/* Expanded content */} - {isOpen && hasDetails && ( - - {args && ( - - - Arguments - - - - {JSON.stringify(args, null, 2)} - - - - )} - {result !== undefined && ( - - - Result - - - - {typeof result === "string" - ? result - : JSON.stringify(result, null, 2)} - - - - )} - + + + {/* Contextual subtitle */} + {subtitle && !isPending && ( + + {subtitle} + )} ); diff --git a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts index 5683fbe39..e1302b142 100644 --- a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts +++ b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts @@ -1,7 +1,5 @@ -import { Audio } from "expo-av"; -import { File } from "expo-file-system"; +import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; import { useCallback, useRef, useState } from "react"; -import { useAuthStore } from "@/features/auth"; type RecordingStatus = "idle" | "recording" | "transcribing" | "error"; @@ -16,148 +14,128 @@ interface UseVoiceRecordingReturn { export function useVoiceRecording(): UseVoiceRecordingReturn { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); - const recordingRef = useRef(null); + const transcriptRef = useRef(""); + const resolveRef = useRef<((text: string | null) => void) | null>(null); + const listenersRef = useRef<(() => void)[]>([]); + + const cleanup = useCallback(() => { + for (const remove of listenersRef.current) { + remove(); + } + listenersRef.current = []; + resolveRef.current = null; + transcriptRef.current = ""; + }, []); const startRecording = useCallback(async () => { try { setError(null); + transcriptRef.current = ""; - // Request permissions - const { granted } = await Audio.requestPermissionsAsync(); - if (!granted) { - setError("Microphone permission is required"); + if (!ExpoSpeechRecognitionModule.isRecognitionAvailable()) { + setError("Speech recognition is not available on this device"); setStatus("error"); return; } - // Configure audio mode for recording - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - - // Create and start recording - const recording = new Audio.Recording(); - await recording.prepareToRecordAsync( - Audio.RecordingOptionsPresets.HIGH_QUALITY, - ); - await recording.startAsync(); - recordingRef.current = recording; - setStatus("recording"); - } catch (err) { - console.error("Failed to start recording:", err); - setError("Failed to start recording"); - setStatus("error"); - } - }, []); - - const stopRecording = useCallback(async (): Promise => { - if (!recordingRef.current) { - return null; - } - - try { - setStatus("transcribing"); - - // Stop recording and get URI - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - recordingRef.current = null; - - // Reset audio mode - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - }); - - if (!uri) { - setError("No recording found"); + const { granted } = + await ExpoSpeechRecognitionModule.requestPermissionsAsync(); + if (!granted) { + setError("Speech recognition permission is required"); setStatus("error"); - return null; + return; } - const { - oauthAccessToken, - cloudRegion, - projectId, - getCloudUrlFromRegion, - } = useAuthStore.getState(); - - if (!oauthAccessToken || !cloudRegion || !projectId) { - setError("Not authenticated"); - setStatus("error"); - return null; - } + // Listen for results — accumulate the latest transcript + const resultSub = ExpoSpeechRecognitionModule.addListener( + "result", + (event) => { + const best = event.results[0]?.transcript; + if (best) { + transcriptRef.current = best; + } + if (event.isFinal && resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }, + ); - const cloudUrl = getCloudUrlFromRegion(cloudRegion); - - // Create form data with the recording file - const formData = new FormData(); - formData.append("file", { - uri, - type: "audio/mp4", - name: "recording.m4a", - } as unknown as Blob); - - // Call PostHog LLM Gateway transcription API - const response = await fetch( - `${cloudUrl}/api/projects/${projectId}/llm_gateway/v1/audio/transcriptions`, - { - method: "POST", - headers: { - Authorization: `Bearer ${oauthAccessToken}`, - }, - body: formData, + const errorSub = ExpoSpeechRecognitionModule.addListener( + "error", + (event) => { + // "no-speech" is not a real error — just means the user didn't say anything + if (event.error === "no-speech") { + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("idle"); + return; + } + setError(event.message || "Speech recognition failed"); + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("error"); }, ); - // Clean up the temp file - const recordingFile = new File(uri); - if (recordingFile.exists) { - await recordingFile.delete(); - } + // If recognition ends without a final result (e.g. silence timeout) + const endSub = ExpoSpeechRecognitionModule.addListener("end", () => { + if (resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }); - if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Transcription failed: ${errorData}`); - } + listenersRef.current = [ + () => resultSub.remove(), + () => errorSub.remove(), + () => endSub.remove(), + ]; + + const useOnDevice = + ExpoSpeechRecognitionModule.supportsOnDeviceRecognition(); + + ExpoSpeechRecognitionModule.start({ + lang: "en-US", + interimResults: true, + requiresOnDeviceRecognition: useOnDevice, + addsPunctuation: true, + }); - const data = await response.json(); - setStatus("idle"); - return data.text; + setStatus("recording"); } catch (err) { - console.error("Failed to transcribe:", err); - const errorMessage = - err instanceof Error ? err.message : "Transcription failed"; - setError(errorMessage); + console.error("Failed to start speech recognition:", err); + setError("Failed to start speech recognition"); setStatus("error"); - return null; } - }, []); + }, [cleanup]); - const cancelRecording = useCallback(async () => { - if (recordingRef.current) { - try { - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - if (uri) { - const file = new File(uri); - if (file.exists) { - await file.delete(); - } - } - } catch { - // Ignore cleanup errors - } - recordingRef.current = null; + const stopRecording = useCallback(async (): Promise => { + if (status !== "recording") { + return null; } - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, + setStatus("transcribing"); + + return new Promise((resolve) => { + resolveRef.current = resolve; + // stop() asks the recognizer to deliver a final result then end + ExpoSpeechRecognitionModule.stop(); }); + }, [status]); + const cancelRecording = useCallback(async () => { + ExpoSpeechRecognitionModule.abort(); + cleanup(); setStatus("idle"); setError(null); - }, []); + }, [cleanup]); return { status, diff --git a/apps/mobile/src/features/chat/index.ts b/apps/mobile/src/features/chat/index.ts index 3ca5c7509..cc4d7c8d1 100644 --- a/apps/mobile/src/features/chat/index.ts +++ b/apps/mobile/src/features/chat/index.ts @@ -11,7 +11,7 @@ export type { ToolMessageProps, ToolStatus, } from "./components/ToolMessage"; -export { ToolMessage } from "./components/ToolMessage"; +export { deriveToolKind, ToolMessage } from "./components/ToolMessage"; export { VisualizationArtifact } from "./components/VisualizationArtifact"; // Hooks diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts new file mode 100644 index 000000000..1e44c1cce --- /dev/null +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -0,0 +1,29 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface PreferencesState { + aiChatEnabled: boolean; + setAiChatEnabled: (enabled: boolean) => void; + pingsEnabled: boolean; + setPingsEnabled: (enabled: boolean) => void; +} + +export const usePreferencesStore = create()( + persist( + (set) => ({ + aiChatEnabled: false, + setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), + pingsEnabled: true, + setPingsEnabled: (enabled) => set({ pingsEnabled: enabled }), + }), + { + name: "posthog-preferences", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + aiChatEnabled: state.aiChatEnabled, + pingsEnabled: state.pingsEnabled, + }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 10f023349..7e35ce882 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -160,16 +160,52 @@ export async function deleteTask(taskId: string): Promise { } } -export async function runTaskInCloud(taskId: string): Promise { +export interface RunTaskInCloudOptions { + branch?: string | null; + resumeFromRunId?: string; + pendingUserMessage?: string; + mode?: "interactive" | "background"; +} + +export async function runTaskInCloud( + taskId: string, + options?: RunTaskInCloudOptions, +): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); const headers = getHeaders(); + // Only serialize a body when we have options to send. Sending an empty + // or minimal body on the initial run historically changed backend + // behavior, so we preserve the "no body" path for the common case. + const hasOptions = + !!options && + (options.branch !== undefined || + options.resumeFromRunId !== undefined || + options.pendingUserMessage !== undefined || + options.mode !== undefined); + + let body: string | undefined; + if (hasOptions) { + const payload: Record = { + mode: options?.mode ?? "interactive", + }; + if (options?.branch) payload.branch = options.branch; + if (options?.resumeFromRunId) { + payload.resume_from_run_id = options.resumeFromRunId; + } + if (options?.pendingUserMessage) { + payload.pending_user_message = options.pendingUserMessage; + } + body = JSON.stringify(payload); + } + const response = await fetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`, { method: "POST", headers, + body, }, ); @@ -228,10 +264,113 @@ export async function appendTaskRunLog( ); } +/** + * Structured error thrown by `sendCloudCommand`. Exposes the HTTP status and + * the backend error payload so callers can branch on specific failure modes + * (e.g. "No active sandbox for this task run" → trigger a resume flow). + */ +export class CloudCommandError extends Error { + readonly status: number; + readonly backendError: string | null; + readonly method: string; + + constructor( + method: string, + status: number, + backendError: string | null, + message: string, + ) { + super(message); + this.name = "CloudCommandError"; + this.method = method; + this.status = status; + this.backendError = backendError; + } + + /** True when the cloud sandbox for this run has terminated. */ + isSandboxInactive(): boolean { + return ( + !!this.backendError?.includes("No active sandbox") || + !!this.backendError?.includes("returned 404") || + this.status === 404 + ); + } +} + +/** + * Sends a JSON-RPC command to a running cloud task. This is the correct path + * for delivering follow-up user prompts to the agent — it gets translated into + * `session/prompt` on the agent side. Note: `appendTaskRunLog` only writes to + * S3 for display; it does NOT notify the agent. + */ +export async function sendCloudCommand( + taskId: string, + runId: string, + method: string, + params: Record = {}, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const body = { + jsonrpc: "2.0", + method, + params, + id: `posthog-mobile-${Date.now()}`, + }; + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/command/`, + { + method: "POST", + headers, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + let backendError: string | null = null; + try { + const parsed = JSON.parse(text); + backendError = + typeof parsed?.error === "string" + ? parsed.error + : (parsed?.error?.message ?? null); + } catch { + backendError = text || null; + } + throw new CloudCommandError( + method, + response.status, + backendError, + `Cloud command '${method}' failed: ${response.status} ${response.statusText} ${text}`, + ); + } + + const data = await response.json(); + if (data?.error) { + const message = + typeof data.error === "string" + ? data.error + : (data.error.message ?? JSON.stringify(data.error)); + throw new CloudCommandError( + method, + 200, + message, + `Cloud command '${method}' error: ${message}`, + ); + } + return data?.result; +} + export async function fetchS3Logs(logUrl: string): Promise { return withRetry( async () => { - const response = await fetch(logUrl); + const response = await fetch(logUrl, { + signal: AbortSignal.timeout(10_000), + }); if (!response.ok) { if (response.status === 404) { @@ -281,17 +420,13 @@ export async function getGithubRepositories( } const data = await response.json(); - - const integrations = await getIntegrations(); - const integration = integrations.find((i) => i.id === integrationId); - const organization = - integration?.display_name || - integration?.config?.account?.login || - "unknown"; - - const repoNames = data.repositories ?? data.results ?? data ?? []; - return repoNames.map( - (repoName: string) => - `${organization.toLowerCase()}/${repoName.toLowerCase()}`, - ); + const repos: Array = + data.repositories ?? data.results ?? data ?? []; + + return repos + .map((repo) => { + if (typeof repo === "string") return repo.toLowerCase(); + return (repo.full_name ?? repo.name ?? "").toLowerCase(); + }) + .filter((name) => name.length > 0); } diff --git a/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx new file mode 100644 index 000000000..2c8633cc5 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx @@ -0,0 +1,88 @@ +import { CheckCircle, CircleDashed, XCircle } from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { PlanEntry } from "../types"; + +interface PlanStatusBarProps { + plan: PlanEntry[] | null; +} + +function StatusIcon({ status }: { status: string }) { + const themeColors = useThemeColors(); + + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + case "failed": + return ; + default: + return ; + } +} + +export function PlanStatusBar({ plan }: PlanStatusBarProps) { + const [isExpanded, setIsExpanded] = useState(false); + const themeColors = useThemeColors(); + + const stats = useMemo(() => { + if (!plan?.length) return null; + + const completed = plan.filter((e) => e.status === "completed").length; + const total = plan.length; + const inProgress = plan.find((e) => e.status === "in_progress"); + const allCompleted = completed === total; + + return { completed, total, inProgress, allCompleted }; + }, [plan]); + + if (!stats || stats.allCompleted) return null; + + return ( + + setIsExpanded(!isExpanded)} + className="flex-row items-center gap-2 px-4 py-2.5" + > + + {stats.completed}/{stats.total} completed + + {stats.inProgress && ( + <> + · + + + {stats.inProgress.content} + + + )} + + + {isExpanded && plan && ( + + {plan.map((entry) => ( + + + + {entry.content} + + + ))} + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/QuestionCard.tsx b/apps/mobile/src/features/tasks/components/QuestionCard.tsx new file mode 100644 index 000000000..6cca5c807 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/QuestionCard.tsx @@ -0,0 +1,378 @@ +import { + ChatCircle, + CheckCircle, + CircleDashed, + RadioButton, +} from "phosphor-react-native"; +import { useState } from "react"; +import { + Pressable, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import type { ToolStatus } from "@/features/chat"; +import { useThemeColors } from "@/lib/theme"; + +interface QuestionOption { + label: string; + description?: string; +} + +interface QuestionItem { + question: string; + header?: string; + options: QuestionOption[]; + multiSelect?: boolean; +} + +interface ToolData { + toolName: string; + toolCallId: string; + status: ToolStatus; + args?: Record; + result?: unknown; +} + +interface PermissionResponseArgs { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; +} + +interface QuestionCardProps { + toolData: ToolData; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; +} + +function extractQuestions(args?: Record): QuestionItem[] { + if (!args) return []; + // Questions may be at top level or nested under input + const raw = + args.questions ?? (args.input as Record)?.questions; + if (!Array.isArray(raw)) return []; + return raw.filter( + (q): q is QuestionItem => + q != null && + typeof q === "object" && + typeof (q as QuestionItem).question === "string" && + Array.isArray((q as QuestionItem).options), + ); +} + +function extractAnswer(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + if (typeof obj.answer === "string") return obj.answer; + if (typeof obj.answers === "object" && obj.answers) { + const answers = obj.answers as Record; + return Object.values(answers).join(", "); + } + if (typeof obj.text === "string") return obj.text; + if (typeof obj.content === "string") return obj.content; + } + return null; +} + +export function QuestionCard({ + toolData, + onSendPermissionResponse, +}: QuestionCardProps) { + const themeColors = useThemeColors(); + const questions = extractQuestions(toolData.args); + const isCompleted = + toolData.status === "completed" || toolData.status === "error"; + + if (questions.length === 0) { + return null; + } + + if (isCompleted) { + const answer = extractAnswer(toolData.result); + return ( + + + + + {questions[0]?.header ?? "Question"} + + + + + {questions[0]?.question} + + {answer && ( + + + + {answer} + + + )} + + + ); + } + + return ( + + ); +} + +function InteractiveQuestion({ + questions, + toolCallId, + onSendPermissionResponse, +}: { + questions: QuestionItem[]; + toolCallId: string; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; +}) { + const themeColors = useThemeColors(); + const [currentIndex, setCurrentIndex] = useState(0); + const [selectedOptions, setSelectedOptions] = useState< + Map> + >(new Map()); + const [otherTexts, setOtherTexts] = useState>(new Map()); + const [showOtherInput, setShowOtherInput] = useState>( + new Map(), + ); + + const question = questions[currentIndex]; + if (!question) return null; + + const isMultiSelect = question.multiSelect ?? false; + const isLastQuestion = currentIndex === questions.length - 1; + const selected = selectedOptions.get(currentIndex) ?? new Set(); + const otherText = otherTexts.get(currentIndex) ?? ""; + const isOtherShown = showOtherInput.get(currentIndex) ?? false; + const hasSelection = selected.size > 0 || otherText.trim().length > 0; + + const toggleOption = (label: string) => { + const newSelected = new Map(selectedOptions); + const current = new Set(selected); + + if (isMultiSelect) { + if (current.has(label)) { + current.delete(label); + } else { + current.add(label); + } + } else { + if (current.has(label)) { + current.clear(); + } else { + current.clear(); + current.add(label); + } + // Clear "Other" when selecting a preset option + const newOther = new Map(showOtherInput); + newOther.set(currentIndex, false); + setShowOtherInput(newOther); + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, ""); + setOtherTexts(newTexts); + } + + newSelected.set(currentIndex, current); + setSelectedOptions(newSelected); + }; + + const toggleOther = () => { + const newOther = new Map(showOtherInput); + const isNowShown = !isOtherShown; + newOther.set(currentIndex, isNowShown); + setShowOtherInput(newOther); + + if (!isMultiSelect && isNowShown) { + // Clear preset selections when choosing "Other" in single-select + const newSelected = new Map(selectedOptions); + newSelected.set(currentIndex, new Set()); + setSelectedOptions(newSelected); + } + }; + + const handleSubmit = () => { + const parts: string[] = []; + for (const label of selected) { + parts.push(label); + } + const trimmedOther = otherText.trim(); + if (trimmedOther) { + parts.push(trimmedOther); + } + const answer = parts.join(", "); + + if (!isLastQuestion) { + setCurrentIndex(currentIndex + 1); + return; + } + + if (!answer || !onSendPermissionResponse) return; + + // Derive the ACP optionId the agent is expecting. Options are built + // server-side (buildQuestionOptions in packages/agent) as + // `${OPTION_PREFIX}${idx}` where OPTION_PREFIX is "option_". If the + // user only typed into "Other", fall back to option_0 — the answers + // map carries the actual content for the agent. + const firstSelectedLabel = parts[0]; + const selectedIdx = question.options.findIndex( + (o) => o.label === firstSelectedLabel, + ); + const optionIdx = selectedIdx >= 0 ? selectedIdx : 0; + const optionId = `option_${optionIdx}`; + + onSendPermissionResponse({ + toolCallId, + optionId, + answers: { [question.question]: answer }, + customInput: trimmedOther || undefined, + displayText: answer, + }); + }; + + return ( + + {/* Header */} + + + + {question.header ?? "Question"} + + {questions.length > 1 && ( + + {currentIndex + 1}/{questions.length} + + )} + + + {/* Question text */} + + + {question.question} + + + + {/* Options */} + + {question.options.map((option) => { + const isSelected = selected.has(option.label); + return ( + toggleOption(option.label)} + className={`mb-1.5 rounded-lg border px-3 py-2.5 ${ + isSelected + ? "border-accent-8 bg-accent-3" + : "border-gray-6 bg-gray-3" + }`} + > + + {isMultiSelect ? ( + isSelected ? ( + + ) : ( + + ) + ) : isSelected ? ( + + ) : ( + + )} + + {option.label} + + + {option.description && ( + + {option.description} + + )} + + ); + })} + + {/* Other option */} + + + Other... + + + + {isOtherShown && ( + { + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, text); + setOtherTexts(newTexts); + }} + multiline + autoFocus + /> + )} + + + {/* Submit */} + + + + {isLastQuestion ? "Submit" : "Next"} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx new file mode 100644 index 000000000..256fdbcb8 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -0,0 +1,143 @@ +import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; +import { useEffect, useRef } from "react"; +import { + Animated, + Easing, + LayoutAnimation, + PanResponder, + Text, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { Task } from "../types"; +import { TaskItem } from "./TaskItem"; + +const SWIPE_THRESHOLD = 60; + +interface SwipeableTaskItemProps { + task: Task; + isArchived: boolean; + onPress: (task: Task) => void; + onArchive: (taskId: string) => void; + onUnarchive: (taskId: string) => void; + onSwipeStart?: () => void; + onSwipeEnd?: () => void; +} + +export function SwipeableTaskItem({ + task, + isArchived, + onPress, + onArchive, + onUnarchive, + onSwipeStart, + onSwipeEnd, +}: SwipeableTaskItemProps) { + const themeColors = useThemeColors(); + const translateX = useRef(new Animated.Value(0)).current; + const actionTriggeredRef = useRef(false); + + // Reset position when the item reappears (e.g. moved between sections) + useEffect(() => { + translateX.setValue(0); + actionTriggeredRef.current = false; + }, [isArchived, translateX]); + + const panResponder = useRef( + PanResponder.create({ + // Start tracking immediately on horizontal movement + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, gesture) => + Math.abs(gesture.dx) > 5 && + Math.abs(gesture.dx) > Math.abs(gesture.dy) && + gesture.dx < 0, + // Capture before children so FlatList doesn't steal + onMoveShouldSetPanResponderCapture: (_, gesture) => + Math.abs(gesture.dx) > 8 && + Math.abs(gesture.dx) > Math.abs(gesture.dy * 1.2) && + gesture.dx < 0, + // Never let go once we have the gesture + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => true, + onPanResponderGrant: () => { + actionTriggeredRef.current = false; + onSwipeStart?.(); + }, + onPanResponderMove: Animated.event( + [null, { dx: translateX }], + { + useNativeDriver: false, + listener: (_: unknown, gesture: { dx: number }) => { + // Clamp to left-only + if (gesture.dx > 0) translateX.setValue(0); + }, + }, + ), + onPanResponderRelease: (_, gesture) => { + onSwipeEnd?.(); + if (gesture.dx < -SWIPE_THRESHOLD && !actionTriggeredRef.current) { + actionTriggeredRef.current = true; + Animated.timing(translateX, { + toValue: -400, + duration: 150, + easing: Easing.in(Easing.ease), + useNativeDriver: true, + }).start(() => { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ); + if (isArchived) { + onUnarchive(task.id); + } else { + onArchive(task.id); + } + }); + } else { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + tension: 40, + friction: 8, + }).start(); + } + }, + onPanResponderTerminate: () => { + onSwipeEnd?.(); + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + }).start(); + }, + }), + ).current; + + const actionBg = isArchived ? themeColors.accent[9] : themeColors.gray[8]; + const ActionIcon = isArchived ? ArrowCounterClockwise : Archive; + const actionLabel = isArchived ? "Restore" : "Archive"; + + return ( + + {/* Action revealed behind the row */} + + + + {actionLabel} + + + + {/* Sliding task row */} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index 6b92195d7..e067abda7 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -36,7 +36,7 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { const prUrl = task.latest_run?.output?.pr_url as string | undefined; const hasPR = !!prUrl; const status = hasPR ? "completed" : task.latest_run?.status || "backlog"; - const isCloudTask = task.latest_run?.environment === "cloud"; + const environment = task.latest_run?.environment; const statusColors = statusColorMap[status] || statusColorMap.backlog; @@ -56,10 +56,15 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { - {/* Cloud indicator */} - {isCloudTask && ( - - ☁️ + {/* Environment badge */} + {environment === "cloud" && ( + + Cloud + + )} + {environment === "local" && ( + + Local )} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 88e91a10f..fbcd8c9dc 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,5 +1,7 @@ import { Text } from "@components/text"; import * as WebBrowser from "expo-web-browser"; +import { CaretRight } from "phosphor-react-native"; +import { useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -11,8 +13,9 @@ import { useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; import { useIntegrations } from "../hooks/useIntegrations"; import { useTasks } from "../hooks/useTasks"; +import { useArchivedTasksStore } from "../stores/archivedTasksStore"; import type { Task } from "../types"; -import { TaskItem } from "./TaskItem"; +import { SwipeableTaskItem } from "./SwipeableTaskItem"; interface TaskListProps { onTaskPress?: (taskId: string) => void; @@ -108,11 +111,18 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { ); } +type ListItem = + | { type: "task"; task: Task; isArchived: boolean } + | { type: "archived-header"; count: number; expanded: boolean }; + export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { const { tasks, isLoading, error, refetch } = useTasks(); const { hasGithubIntegration, refetch: refetchIntegrations } = useIntegrations(); const themeColors = useThemeColors(); + const { archivedTasks, archive, unarchive } = useArchivedTasksStore(); + const [archivedExpanded, setArchivedExpanded] = useState(false); + const [scrollEnabled, setScrollEnabled] = useState(true); const handleTaskPress = (task: Task) => { onTaskPress?.(task.id); @@ -122,6 +132,46 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { await Promise.all([refetch(), refetchIntegrations()]); }; + const listItems = useMemo((): ListItem[] => { + const active: Task[] = []; + const archived: Task[] = []; + + for (const task of tasks) { + if (task.id in archivedTasks) { + archived.push(task); + } else { + active.push(task); + } + } + + // Sort archived by FIFO (earliest archived first) + archived.sort( + (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), + ); + + const items: ListItem[] = active.map((task) => ({ + type: "task", + task, + isArchived: false, + })); + + if (archived.length > 0) { + items.push({ + type: "archived-header", + count: archived.length, + expanded: archivedExpanded, + }); + + if (archivedExpanded) { + for (const task of archived) { + items.push({ type: "task", task, isArchived: true }); + } + } + } + + return items; + }, [tasks, archivedTasks, archivedExpanded]); + if (error) { return ( @@ -160,14 +210,51 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { return ; } - // Has tasks - show the list (regardless of GitHub connection status) return ( item.id} - renderItem={({ item }) => ( - - )} + scrollEnabled={scrollEnabled} + data={listItems} + keyExtractor={(item) => + item.type === "archived-header" + ? "__archived_header__" + : `${item.task.id}-${item.isArchived ? "a" : "v"}` + } + renderItem={({ item }) => { + if (item.type === "archived-header") { + return ( + setArchivedExpanded(!item.expanded)} + className="flex-row items-center gap-2 border-gray-6 border-t bg-gray-2 px-3 py-2.5" + > + + + Archived + + {item.count} + + ); + } + + return ( + setScrollEnabled(false)} + onSwipeEnd={() => setScrollEnabled(true)} + /> + ); + }} refreshControl={ ; + customInput?: string; + displayText: string; +} interface TaskSessionViewProps { events: SessionEvent[]; - isPromptPending: boolean; + isConnecting?: boolean; + isThinking?: boolean; + terminalStatus?: "failed" | "completed"; + lastError?: string | null; + onRetry?: () => void; onOpenTask?: (taskId: string) => void; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; contentContainerStyle?: object; } @@ -22,13 +51,17 @@ interface ToolData { status: ToolStatus; args?: Record; result?: unknown; + isAgent?: boolean; + parentToolCallId?: string; } interface ParsedMessage { id: string; - type: "user" | "agent" | "tool"; + type: "user" | "agent" | "thought" | "tool" | "connecting" | "thinking"; content: string; + ts?: number; toolData?: ToolData; + children?: ParsedMessage[]; } function mapToolStatus( @@ -48,11 +81,14 @@ function mapToolStatus( } } -function parseSessionNotification(notification: SessionNotification): { - type: "user" | "agent" | "tool" | "tool_update"; - content?: string; - toolData?: ToolData; -} | null { +type ParsedNotification = + | { type: "user" | "agent" | "agent_complete" | "thought"; content: string } + | { type: "tool" | "tool_update"; toolData: ToolData } + | { type: "plan"; entries: PlanEntry[] }; + +function parseSessionNotification( + notification: SessionNotification, +): ParsedNotification | null { const { update } = notification; if (!update?.sessionUpdate) { return null; @@ -70,7 +106,27 @@ function parseSessionNotification(notification: SessionNotification): { } return null; } + // `agent_message` is the aggregated final message emitted by the server + // once a response is complete. If we already received streaming chunks, + // this is a duplicate — replace pending text instead of appending. + case "agent_message": { + if (update.content?.type === "text") { + return { + type: "agent_complete" as const, + content: update.content.text, + }; + } + return null; + } + case "agent_thought_chunk": { + if (update.content?.type === "text") { + return { type: "thought", content: update.content.text }; + } + return null; + } case "tool_call": { + const meta = update._meta?.claudeCode; + const isAgent = meta?.toolName === "Agent" || meta?.toolName === "Task"; return { type: "tool", toolData: { @@ -78,10 +134,13 @@ function parseSessionNotification(notification: SessionNotification): { toolCallId: update.toolCallId ?? "", status: mapToolStatus(update.status), args: update.rawInput, + isAgent, + parentToolCallId: meta?.parentToolCallId, }, }; } case "tool_call_update": { + const meta = update._meta?.claudeCode; return { type: "tool_update", toolData: { @@ -90,31 +149,127 @@ function parseSessionNotification(notification: SessionNotification): { status: mapToolStatus(update.status), args: update.rawInput, result: update.rawOutput, + parentToolCallId: meta?.parentToolCallId, }, }; } + case "plan": { + if (Array.isArray(update.entries)) { + return { type: "plan", entries: update.entries }; + } + return null; + } default: return null; } } -function processEvents(events: SessionEvent[]): ParsedMessage[] { - const messages: ParsedMessage[] = []; - let pendingAgentText = ""; - let agentMessageCount = 0; - const toolMessages = new Map(); +interface ProcessedEvents { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; +} + +function isQuestionTool(toolData?: ToolData): boolean { + if (!toolData) return false; + if (toolData.toolName.toLowerCase().includes("question")) return true; + if (Array.isArray(toolData.args?.questions)) return true; + return false; +} + +// Mutable processor state persisted across renders via useRef. +// Only new events (past processedIdx) are processed on each call. +interface EventProcessorState { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; + pendingAgentText: string; + pendingAgentTs?: number; + pendingThoughtText: string; + lastAgentMsgIdx: number | null; + agentMessageCount: number; + thoughtMessageCount: number; + userMessageCount: number; + toolMessages: Map; + // Maps agent toolCallId → agent ParsedMessage for nesting children + agentTools: Map; + processedIdx: number; + // Snapshot tracking: only create a new array ref when messages grow. + // Mutations (tool_update, agent_complete replacing content) reuse the + // same snapshot so FlatList doesn't re-layout and reset scroll position. + lastSnapshot: ParsedMessage[]; + lastSnapshotLength: number; +} + +function createProcessorState(): EventProcessorState { + return { + messages: [], + plan: null, + pendingAgentText: "", + pendingThoughtText: "", + lastAgentMsgIdx: null, + agentMessageCount: 0, + thoughtMessageCount: 0, + userMessageCount: 0, + toolMessages: new Map(), + agentTools: new Map(), + processedIdx: 0, + lastSnapshot: [], + lastSnapshotLength: 0, + }; +} + +function processNewEvents( + state: EventProcessorState, + events: SessionEvent[], +): ProcessedEvents { + // If events shrank (e.g. session reset), start fresh + if (events.length < state.processedIdx) { + Object.assign(state, createProcessorState()); + } + + // Nothing new to process + if (events.length === state.processedIdx) { + return { messages: state.messages, plan: state.plan }; + } const flushAgentText = () => { - if (!pendingAgentText) return; - messages.push({ - id: `agent-${agentMessageCount++}`, + if (!state.pendingAgentText) return; + const msg: ParsedMessage = { + id: `agent-${state.agentMessageCount++}`, type: "agent", - content: pendingAgentText, - }); - pendingAgentText = ""; + content: state.pendingAgentText, + ts: state.pendingAgentTs, + }; + state.messages.push(msg); + state.lastAgentMsgIdx = state.messages.length - 1; + state.pendingAgentText = ""; + state.pendingAgentTs = undefined; + }; + + const flushThoughtText = () => { + if (!state.pendingThoughtText) return; + // Merge consecutive thoughts into one message instead of many rows + const lastMsg = state.messages[state.messages.length - 1]; + if (lastMsg?.type === "thought") { + lastMsg.content += state.pendingThoughtText; + } else { + state.messages.push({ + id: `thought-${state.thoughtMessageCount++}`, + type: "thought", + content: state.pendingThoughtText, + }); + } + state.pendingThoughtText = ""; + }; + + const flushPending = () => { + flushThoughtText(); + flushAgentText(); }; - for (const event of events) { + let hasItemMutation = false; + + for (let i = state.processedIdx; i < events.length; i++) { + const event = events[i]; if (event.type !== "session_update") continue; const parsed = parseSessionNotification(event.notification); @@ -122,103 +277,580 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { switch (parsed.type) { case "user": - flushAgentText(); - messages.push({ - id: `user-${event.ts}`, + flushPending(); + state.messages.push({ + id: `user-${state.userMessageCount++}`, type: "user", content: parsed.content ?? "", + ts: event.ts, }); + state.lastAgentMsgIdx = null; break; case "agent": - pendingAgentText += parsed.content ?? ""; + flushThoughtText(); + if (!state.pendingAgentTs) state.pendingAgentTs = event.ts; + state.pendingAgentText += parsed.content ?? ""; break; - case "tool": + case "agent_complete": + flushThoughtText(); + // If we already flushed an agent message from chunks, replace it + if ( + state.lastAgentMsgIdx !== null && + state.messages[state.lastAgentMsgIdx]?.type === "agent" + ) { + state.messages[state.lastAgentMsgIdx].content = parsed.content ?? ""; + if (!state.messages[state.lastAgentMsgIdx].ts) { + state.messages[state.lastAgentMsgIdx].ts = event.ts; + } + state.pendingAgentText = ""; + state.pendingAgentTs = undefined; + } else { + state.pendingAgentText = parsed.content ?? ""; + if (!state.pendingAgentTs) state.pendingAgentTs = event.ts; + } + break; + case "thought": flushAgentText(); + state.pendingThoughtText += parsed.content ?? ""; + break; + case "plan": + state.plan = parsed.entries; + break; + case "tool": + flushPending(); if (parsed.toolData) { - const msg: ParsedMessage = { - id: `tool-${parsed.toolData.toolCallId}`, - type: "tool", - content: "", - toolData: parsed.toolData, - }; - toolMessages.set(parsed.toolData.toolCallId, msg); - messages.push(msg); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); + if (existing?.toolData) { + existing.toolData = { + ...existing.toolData, + ...parsed.toolData, + }; + } else { + const msg: ParsedMessage = { + id: `tool-${parsed.toolData.toolCallId}`, + type: "tool", + content: "", + toolData: parsed.toolData, + children: parsed.toolData.isAgent ? [] : undefined, + }; + state.toolMessages.set(parsed.toolData.toolCallId, msg); + + // Agent tools: register for child nesting + if (parsed.toolData.isAgent) { + state.agentTools.set(parsed.toolData.toolCallId, msg); + } + + // Child tools: nest under parent agent instead of top-level + const parentId = parsed.toolData.parentToolCallId; + const parent = parentId + ? state.agentTools.get(parentId) + : undefined; + if (parent?.children) { + parent.children.push(msg); + hasItemMutation = true; + } else { + state.messages.push(msg); + } + } } + state.lastAgentMsgIdx = null; break; case "tool_update": if (parsed.toolData) { - const existing = toolMessages.get(parsed.toolData.toolCallId); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); if (existing?.toolData) { existing.toolData.status = parsed.toolData.status; existing.toolData.result = parsed.toolData.result; + if (parsed.toolData.args) { + existing.toolData.args = parsed.toolData.args; + } + hasItemMutation = true; } } break; } } - flushAgentText(); - return messages; + flushPending(); + state.processedIdx = events.length; + + // Create a new array reference when messages were added or when a tool + // received args for the first time (so the diff view can render). + // Pure status/text mutations reuse the prior snapshot to avoid jumps. + if (state.messages.length !== state.lastSnapshotLength || hasItemMutation) { + state.lastSnapshot = [...state.messages]; + state.lastSnapshotLength = state.messages.length; + } + + return { messages: state.lastSnapshot, plan: state.plan }; +} + +function CollapsedThought({ content }: { content: string }) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + + return ( + setExpanded(!expanded)} className="px-4 py-0.5"> + + + Thought + + {expanded && ( + + {content} + + )} + + ); +} + +// Detect objects like {"0":"E","1":"r","2":"r",...,"isError":true} — a string +// serialized as char-per-key (possibly with extra metadata keys mixed in). +function tryReassembleString(obj: Record): string | null { + const numericKeys = Object.keys(obj).filter((k) => /^\d+$/.test(k)); + if (numericKeys.length < 3) return null; + if ( + numericKeys.every( + (k) => typeof obj[k] === "string" && (obj[k] as string).length === 1, + ) + ) { + return numericKeys + .sort((a, b) => Number(a) - Number(b)) + .map((k) => obj[k]) + .join(""); + } + return null; +} + +function extractErrorText(result: unknown): string | null { + if (typeof result === "string") return result; + if (Array.isArray(result)) { + const texts = result.map(extractErrorText).filter(Boolean); + return texts.length > 0 ? texts.join("\n") : null; + } + if (!result || typeof result !== "object") return null; + const obj = result as Record; + + // Reassemble char-per-key strings: {"0":"E","1":"r",...} + const reassembled = tryReassembleString(obj); + if (reassembled) return reassembled; + + // Check simple string fields, recurse into nested objects + for (const key of [ + "error", + "message", + "stderr", + "output", + "text", + "content", + ]) { + if (typeof obj[key] === "string") return obj[key] as string; + if (obj[key] && typeof obj[key] === "object") { + const nested = extractErrorText(obj[key]); + if (nested) return nested; + } + } + + // Last resort: stringify the result so *something* shows + try { + const str = JSON.stringify(result, null, 2); + if (str && str !== "{}") return str; + } catch { + // ignore + } + + return null; +} + +function agentPromptSummary(args?: Record): string | null { + if (!args) return null; + const prompt = + typeof args.prompt === "string" + ? args.prompt + : typeof args.description === "string" + ? args.description + : null; + if (!prompt) return null; + // Take the first meaningful line, truncated + const firstLine = prompt + .split("\n") + .find((l) => l.trim()) + ?.trim(); + if (!firstLine) return null; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; +} + +function AgentToolCard({ + item, + onOpenTask, +}: { + item: ParsedMessage; + onOpenTask?: (taskId: string) => void; +}) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + const toolData = item.toolData; + const children = item.children ?? []; + if (!toolData) return null; + + const isLoading = + toolData.status === "pending" || toolData.status === "running"; + const isFailed = toolData.status === "error"; + const childCount = children.length; + const subtitle = agentPromptSummary(toolData.args); + const errorText = isFailed ? extractErrorText(toolData.result) : null; + + return ( + + {/* Header */} + setExpanded(!expanded)} className="px-3 py-2"> + + {isLoading ? ( + + ) : ( + + )} + + {toolData.toolName} + + {childCount > 0 && ( + + {childCount} {childCount === 1 ? "tool" : "tools"} + + )} + {isFailed && ( + + Failed + + )} + + + {subtitle && ( + + {subtitle} + + )} + + + {/* Error message + nested tool calls */} + {expanded && ( + + {errorText && ( + + + {errorText} + + + )} + {children.map((child) => { + if (!child.toolData) return null; + return ( + + ); + })} + + )} + + ); +} + +function formatElapsed(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +function useElapsedTimer() { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + setElapsed(0); + const interval = setInterval(() => { + setElapsed((e) => e + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + return elapsed; +} + +function ThinkingIndicator() { + const themeColors = useThemeColors(); + const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + + + + Thinking{".".repeat(dots)} + + + + {formatElapsed(elapsed)} + + + + ); +} + +function ConnectingIndicator() { + const themeColors = useThemeColors(); + const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + + + + Connecting{".".repeat(dots)} + + + + {formatElapsed(elapsed)} + + + + ); } export function TaskSessionView({ events, - isPromptPending, + isConnecting, + isThinking, + terminalStatus, + lastError, + onRetry, onOpenTask, + onSendPermissionResponse, contentContainerStyle, }: TaskSessionViewProps) { - const messages = useMemo(() => processEvents(events), [events]); + const processorRef = useRef(createProcessorState()); + const prevEventsRef = useRef(events); + // Reset processor when events array shrinks or changes identity completely + // (e.g., navigating between tasks while Expo Router reuses the component). + if ( + events.length === 0 || + (events !== prevEventsRef.current && events[0] !== prevEventsRef.current[0]) + ) { + processorRef.current = createProcessorState(); + } + prevEventsRef.current = events; + const { messages, plan } = useMemo( + () => processNewEvents(processorRef.current, events), + [events], + ); + // Inverted FlatList renders data[0] at the visual bottom. + // Reverse so newest messages are at index 0 = bottom. + const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); + const flatListRef = useRef(null); + const buttonRef = useRef(null); + const isScrolledRef = useRef(false); + + const scrollToBottom = useCallback(() => { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, []); + + const handleScroll = useCallback( + (e: { nativeEvent: { contentOffset: { y: number } } }) => { + const scrolled = e.nativeEvent.contentOffset.y > 0; + if (scrolled !== isScrolledRef.current) { + isScrolledRef.current = scrolled; + buttonRef.current?.setNativeProps({ + style: { + opacity: scrolled ? 1 : 0, + pointerEvents: scrolled ? "auto" : "none", + }, + }); + } + }, + [], + ); const renderMessage = useCallback( ({ item }: { item: ParsedMessage }) => { switch (item.type) { case "user": - return ; + return ; case "agent": return ( - + ); + case "thought": + return ; case "tool": - return item.toolData ? ( + if (!item.toolData) return null; + if (isQuestionTool(item.toolData)) { + return ( + + ); + } + if (item.toolData.isAgent) { + return ; + } + return ( - ) : null; + ); default: return null; } }, - [onOpenTask], + [onOpenTask, onSendPermissionResponse], ); return ( - item.id} - inverted - contentContainerStyle={{ - flexDirection: "column-reverse", - ...contentContainerStyle, - }} - keyboardDismissMode="interactive" - keyboardShouldPersistTaps="handled" - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - isPromptPending ? ( - - - - Thinking... - - - ) : null - } - /> + + + item.id} + inverted + contentContainerStyle={contentContainerStyle} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator + onScroll={handleScroll} + scrollEventThrottle={100} + maxToRenderPerBatch={15} + windowSize={21} + initialNumToRender={30} + ListHeaderComponent={ + terminalStatus ? ( + + + {terminalStatus === "failed" ? "Run failed" : "Run completed"} + + {lastError && ( + {lastError} + )} + {onRetry && ( + + + {terminalStatus === "failed" ? "Retry" : "Continue"} + + + )} + + ) : null + } + /> + {/* Thinking/connecting indicators absolutely positioned above the Composer area. + Rendered outside FlatList to avoid inverted-list double-mount bugs. */} + {(isConnecting || isThinking) && ( + + {isConnecting ? ( + + ) : isThinking ? ( + + ) : null} + + )} + + + + + + ); } diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index c0bb1f812..63727f1d8 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -14,7 +14,7 @@ import type { CreateTaskOptions, Task } from "../types"; export const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { repository?: string; createdBy?: number }) => + list: (filters?: { repository?: string }) => [...taskKeys.lists(), filters] as const, details: () => [...taskKeys.all, "detail"] as const, detail: (id: string) => [...taskKeys.details(), id] as const, @@ -27,7 +27,6 @@ export function useTasks(filters?: { repository?: string }) { const queryFilters = { ...filters, - createdBy: currentUser?.id, }; const query = useQuery({ @@ -64,23 +63,15 @@ export function useTask(taskId: string) { export function useCreateTask() { const queryClient = useQueryClient(); - const { data: currentUser } = useUserQuery(); - const invalidateTasks = (newTask?: Task) => { - if (newTask && currentUser?.id) { - // Update the correct cache entry with the user's filter - const queryKey = taskKeys.list({ createdBy: currentUser.id }); - queryClient.setQueryData(queryKey, (old) => - old ? [newTask, ...old] : [newTask], - ); - } + const invalidateTasks = () => { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); }; const mutation = useMutation({ mutationFn: (options: CreateTaskOptions) => createTask(options), - onSuccess: (newTask) => { - invalidateTasks(newTask); + onSuccess: () => { + invalidateTasks(); }, onError: (error) => { console.error("Failed to create task:", error.message); diff --git a/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts new file mode 100644 index 000000000..8a2def9f4 --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts @@ -0,0 +1,40 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +interface ArchivedTasksState { + // taskId → timestamp (ms) for FIFO ordering + archivedTasks: Record; + archive: (taskId: string) => void; + unarchive: (taskId: string) => void; + isArchived: (taskId: string) => boolean; +} + +export const useArchivedTasksStore = create()( + persist( + (set, get) => ({ + archivedTasks: {}, + + archive: (taskId: string) => + set((state) => ({ + archivedTasks: { + ...state.archivedTasks, + [taskId]: Date.now(), + }, + })), + + unarchive: (taskId: string) => + set((state) => { + const { [taskId]: _, ...rest } = state.archivedTasks; + return { archivedTasks: rest }; + }), + + isArchived: (taskId: string) => taskId in get().archivedTasks, + }), + { + name: "archived-tasks", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ archivedTasks: state.archivedTasks }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index a11ac1383..96609ec9d 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,6 +1,14 @@ import { create } from "zustand"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; -import { appendTaskRunLog, fetchS3Logs, runTaskInCloud } from "../api"; +import { + CloudCommandError, + fetchS3Logs, + getTask, + getTaskRun, + runTaskInCloud, + sendCloudCommand, +} from "../api"; import type { SessionEvent, SessionNotification, @@ -11,6 +19,46 @@ import { convertRawEntriesToEvents, parseSessionLogs, } from "../utils/parseSessionLogs"; +import { playMeepSound } from "../utils/sounds"; + +// Infer whether the agent is actively working or idle (waiting for user input). +// Primary signal: _posthog/turn_complete or _posthog/task_complete in raw log +// entries. Fallback: session update notification heuristic for older logs. +function inferAgentIsIdle( + rawEntries: StoredLogEntry[], + notifications: SessionNotification[], +): boolean { + // Check raw entries for explicit turn/task completion signals + for (let i = rawEntries.length - 1; i >= 0; i--) { + const method = rawEntries[i].notification?.method; + if ( + method === "_posthog/turn_complete" || + method === "_posthog/task_complete" + ) { + return true; + } + // If we hit a client-direction entry (user message), the agent hasn't + // completed a turn since the last user input. + if (rawEntries[i].direction === "client") break; + } + + // Fallback: check session update notifications for agent responses + for (let i = notifications.length - 1; i >= 0; i--) { + const su = notifications[i].update?.sessionUpdate; + if (su === "agent_message" || su === "agent_message_chunk") { + return true; + } + if ( + su === "user_message_chunk" || + su === "tool_call" || + su === "tool_call_update" || + su === "agent_thought_chunk" + ) { + return false; + } + } + return false; +} const CLOUD_POLLING_INTERVAL_MS = 500; @@ -23,6 +71,23 @@ export interface TaskSession { logUrl: string; processedLineCount: number; processedHashes?: Set; + // Content of user prompts echoed locally (before the agent writes them to + // the log). Used by polling to dedup the canonical copy against the echo. + localUserEchoes?: Set; + // Terminal backend status for this run, populated by the status-check + // poller so the UI can surface "Run failed" / "Run completed". + terminalStatus?: "failed" | "completed"; + lastError?: string | null; + // True when the user initiated work (new task, sendPrompt, resume) and + // we should play a sound when control returns. False when reconnecting + // to an already-running task to avoid spurious pings. + awaitingPing?: boolean; + // True after a user prompt is sent, cleared when the first piece of + // agent output (tool call, message, etc.) arrives from polling. + awaitingAgentOutput?: boolean; + // Messages queued while the agent is working. Auto-sent when control + // returns (isPromptPending flips to false). + messageQueue?: string[]; } interface TaskSessionStore { @@ -31,16 +96,42 @@ interface TaskSessionStore { connectToTask: (task: Task) => Promise; disconnectFromTask: (taskId: string) => void; sendPrompt: (taskId: string, prompt: string) => Promise; + sendPermissionResponse: ( + taskId: string, + args: { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; + }, + ) => Promise; cancelPrompt: (taskId: string) => Promise; getSessionForTask: (taskId: string) => TaskSession | undefined; _handleEvent: (taskRunId: string, event: SessionEvent) => void; _startCloudPolling: (taskRunId: string, logUrl: string) => void; _stopCloudPolling: (taskRunId: string) => void; + _resumeCloudRun: ( + taskId: string, + previousRunId: string, + prompt: string, + ) => Promise; } const cloudPollers = new Map>(); const connectAttempts = new Set(); +// Guard against overlapping poll ticks — if a fetch takes >500ms, the next +// interval fires while the previous is still running, causing both to read +// the same processedLineCount and produce duplicate events. +const pollInFlight = new Set(); +// Timestamps for when each poll tick started — used to force-clear stuck ticks. +const pollInFlightSince = new Map(); +const POLL_IN_FLIGHT_TIMEOUT_MS = 30_000; +// Tick counts per task run used to throttle backend task-run status polling. +const pollTicks = new Map(); +// How many S3 polling ticks between each backend task-run status check. +const STATUS_CHECK_TICK_INTERVAL = 5; export const useTaskSessionStore = create((set, get) => ({ sessions: {}, @@ -49,7 +140,7 @@ export const useTaskSessionStore = create((set, get) => ({ const taskId = task.id; const latestRunId = task.latest_run?.id; const latestRunLogUrl = task.latest_run?.log_url; - const taskDescription = task.description; + const _taskDescription = task.description; if (connectAttempts.has(taskId)) { logger.debug("Connection already in progress", { taskId }); @@ -82,24 +173,13 @@ export const useTaskSessionStore = create((set, get) => ({ [newRunId]: { taskRunId: newRunId, taskId, - events: taskDescription - ? [ - { - type: "session_update" as const, - ts: Date.now(), - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }, - ] - : [], + events: [], status: "connected", - isPromptPending: true, // Agent is processing initial task + isPromptPending: true, logUrl: newLogUrl, processedLineCount: 0, + awaitingPing: true, + awaitingAgentOutput: true, }, }, })); @@ -121,29 +201,29 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Loaded cloud historical logs", { notifications: notifications.length, rawEntries: rawEntries.length, + backendStatus: task.latest_run?.status, }); const historicalEvents = convertRawEntriesToEvents( rawEntries, notifications, - taskDescription, ); - // Check if agent is still processing by looking at the last entry - // If the last non-client entry is a user message, agent is likely still working - const lastAgentEntry = [...rawEntries] - .reverse() - .find((e) => e.direction !== "client"); - // biome-ignore lint/suspicious/noExplicitAny: Entry structure varies - const lastUpdate = (lastAgentEntry?.notification as any)?.params?.update - ?.sessionUpdate; - const isAgentResponding = - lastUpdate === "agent_message_chunk" || - lastUpdate === "agent_thought_chunk" || - lastUpdate === "tool_call" || - lastUpdate === "tool_call_update"; - // If we have entries but the last one isn't an agent response, agent may still be processing - const isPromptPending = rawEntries.length > 0 && !isAgentResponding; + // Terminal runs (completed/failed) always clear isPromptPending. + // For non-terminal runs we infer idle vs working from the log shape + // because the backend has no "waiting_for_input" status. + const backendStatus = task.latest_run?.status; + const isTerminal = + backendStatus === "completed" || backendStatus === "failed"; + const terminalStatus: "completed" | "failed" | undefined = isTerminal + ? (backendStatus as "completed" | "failed") + : undefined; + const lastError = isTerminal + ? (task.latest_run?.error_message ?? null) + : null; + + const agentIsIdle = inferAgentIsIdle(rawEntries, notifications); + const isPromptPending = isTerminal ? false : !agentIsIdle; set((state) => ({ sessions: { @@ -156,12 +236,35 @@ export const useTaskSessionStore = create((set, get) => ({ isPromptPending, logUrl: latestRunLogUrl, processedLineCount: rawEntries.length, + terminalStatus, + lastError, + // Show "Connecting/Thinking" for active non-terminal runs + // that haven't produced visible agent output yet. + awaitingAgentOutput: + isPromptPending && + !historicalEvents.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as SessionNotification)?.update + ?.sessionUpdate; + return ( + su === "agent_message_chunk" || + su === "agent_message" || + su === "agent_thought_chunk" || + su === "tool_call" || + su === "tool_call_update" + ); + }), }, }, })); get()._startCloudPolling(latestRunId, latestRunLogUrl); - logger.debug("Connected to cloud session", { taskId, latestRunId }); + logger.debug("Connected to cloud session", { + taskId, + latestRunId, + backendStatus, + isTerminal, + }); } catch (error) { logger.error("Failed to connect to task", error); } finally { @@ -188,67 +291,193 @@ export const useTaskSessionStore = create((set, get) => ({ throw new Error("No active session for task"); } - const notification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", - notification: { - method: "session/update", - params: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: prompt }, + // If the agent is still working, queue the message for later. + if (session.isPromptPending) { + logger.debug("Agent busy, queuing message", { taskId }); + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + messageQueue: [...(current.messageQueue ?? []), prompt], + }, }, + }; + }); + return; + } + + // Local echo for immediate UX feedback — polling will re-surface the + // canonical copy once the agent writes it to the log; any duplicate is + // removed by content-based dedup in the polling loop below. + const ts = Date.now(); + const userEvent: SessionEvent = { + type: "session_update", + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: prompt }, }, }, }; - await appendTaskRunLog(taskId, session.taskRunId, [notification]); - logger.debug("Sent cloud message via S3", { - taskId, - runId: session.taskRunId, + set((state) => { + const current = state.sessions[session.taskRunId]; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + awaitingPing: true, + awaitingAgentOutput: true, + }, + }, + }; }); + try { + await sendCloudCommand(taskId, session.taskRunId, "user_message", { + content: prompt, + }); + logger.debug("Sent cloud command user_message", { + taskId, + runId: session.taskRunId, + }); + } catch (err) { + // Sandbox for this run has shut down — create a resume run on the + // backend and swap the local session to the new run id. + let rollbackError: unknown = err; + if (err instanceof CloudCommandError && err.isSandboxInactive()) { + logger.info("Sandbox inactive, creating resume run", { + taskId, + previousRunId: session.taskRunId, + }); + try { + await get()._resumeCloudRun(taskId, session.taskRunId, prompt); + return; + } catch (resumeErr) { + logger.error("Failed to resume cloud run", resumeErr); + rollbackError = resumeErr; + } + } + + // Roll back the local echo + pending state so the user can retry. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw rollbackError; + } + }, + + // Resolve an outstanding requestPermission on the desktop/agent side + // (e.g. AskUserQuestion). Unlike sendPrompt, this never queues — a + // permission reply only makes sense while the agent is paused inside + // requestPermission, and it completes an existing turn rather than + // starting a new one. + sendPermissionResponse: async (taskId, args) => { + const session = get().getSessionForTask(taskId); + if (!session) { + throw new Error("No active session for task"); + } + const ts = Date.now(); const userEvent: SessionEvent = { type: "session_update", ts, - notification: notification.notification?.params as SessionNotification, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: args.displayText }, + }, + }, }; - set((state) => ({ - sessions: { - ...state.sessions, - [session.taskRunId]: { - ...state.sessions[session.taskRunId], - events: [...state.sessions[session.taskRunId].events, userEvent], - processedLineCount: - (state.sessions[session.taskRunId].processedLineCount ?? 0) + 1, - isPromptPending: true, + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + awaitingPing: true, + awaitingAgentOutput: true, + }, }, - }, - })); + }; + }); + + try { + await sendCloudCommand(taskId, session.taskRunId, "permission_response", { + toolCallId: args.toolCallId, + optionId: args.optionId, + ...(args.answers ? { answers: args.answers } : {}), + ...(args.customInput ? { customInput: args.customInput } : {}), + }); + logger.debug("Sent permission_response", { + taskId, + runId: session.taskRunId, + toolCallId: args.toolCallId, + }); + } catch (err) { + logger.error("Failed to send permission_response", err); + // Roll back the optimistic state so the UI reflects reality. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw err; + } }, cancelPrompt: async (taskId: string) => { const session = get().getSessionForTask(taskId); if (!session) return false; - const cancelNotification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", - notification: { - method: "session/cancel", - params: { - sessionId: session.taskRunId, - }, - }, - }; - try { - await appendTaskRunLog(taskId, session.taskRunId, [cancelNotification]); - logger.debug("Sent cancel request via S3", { + await sendCloudCommand(taskId, session.taskRunId, "cancel"); + logger.debug("Sent cancel command", { taskId, runId: session.taskRunId, }); @@ -295,6 +524,17 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Starting cloud S3 polling", { taskRunId }); const pollS3 = async () => { + // Skip if previous tick is still in flight — but force-clear if stuck + if (pollInFlight.has(taskRunId)) { + const startedAt = pollInFlightSince.get(taskRunId) ?? 0; + if (Date.now() - startedAt < POLL_IN_FLIGHT_TIMEOUT_MS) return; + logger.warn("Force-clearing stuck pollInFlight", { taskRunId }); + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); + } + pollInFlight.add(taskRunId); + pollInFlightSince.set(taskRunId, Date.now()); + try { const session = get().sessions[taskRunId]; if (!session) { @@ -302,6 +542,56 @@ export const useTaskSessionStore = create((set, get) => ({ return; } + // Check backend status periodically, or every tick while the agent + // is pending (so "Thinking..." clears promptly when the run finishes). + const tick = (pollTicks.get(taskRunId) ?? 0) + 1; + pollTicks.set(taskRunId, tick); + const shouldCheckStatus = + session.isPromptPending || tick % STATUS_CHECK_TICK_INTERVAL === 0; + if (shouldCheckStatus) { + try { + const run = await getTaskRun(session.taskId, taskRunId); + logger.debug("Status check", { + taskRunId, + status: run.status, + error: run.error_message, + }); + if (run.status === "failed" || run.status === "completed") { + logger.debug("Backend run reached terminal status", { + taskRunId, + status: run.status, + error: run.error_message, + }); + const shouldPing = + get().sessions[taskRunId]?.awaitingPing ?? false; + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + isPromptPending: false, + terminalStatus: run.status as "failed" | "completed", + lastError: run.error_message, + awaitingPing: false, + messageQueue: undefined, + }, + }, + }; + }); + if (shouldPing && usePreferencesStore.getState().pingsEnabled) { + playMeepSound().catch(() => {}); + } + } + } catch (statusErr) { + logger.warn("Failed to fetch task run status", { + error: statusErr, + }); + } + } + const text = await fetchS3Logs(logUrl); if (!text) return; @@ -310,9 +600,20 @@ export const useTaskSessionStore = create((set, get) => ({ if (lines.length > processedCount) { const newLines = lines.slice(processedCount); + logger.debug("Poll picked up new log lines", { + taskRunId, + newLineCount: newLines.length, + totalLines: lines.length, + }); const currentHashes = new Set(session.processedHashes ?? []); - + const remainingLocalEchoes = new Set(session.localUserEchoes ?? []); + // Collect all new events in a batch, then do a single store + // update. This prevents N re-renders per poll tick. + const batchedEvents: SessionEvent[] = []; let receivedAgentMessage = false; + // Track when a user_message_chunk arrives that wasn't sent from + // this device — means someone prompted from the desktop app. + let receivedExternalUserMessage = false; for (const line of newLines) { try { @@ -321,45 +622,83 @@ export const useTaskSessionStore = create((set, get) => ({ ? new Date(entry.timestamp).getTime() : Date.now(); - const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}-${entry.direction ?? ""}`; + // Build a dedup hash specific enough to distinguish different + // events at the same timestamp. For session/update entries, + // include the update type, toolCallId, and status so that a + // tool_call and its tool_call_update don't collide. + const params = entry.notification?.params; + const suDetail = params?.update + ? `-${params.update.sessionUpdate ?? ""}-${params.update.toolCallId ?? ""}-${params.update.status ?? ""}` + : `-${entry.direction ?? ""}`; + const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}${suDetail}`; if (currentHashes.has(hash)) { continue; } currentHashes.add(hash); - const isClientMessage = entry.direction === "client"; - if (isClientMessage) { - continue; + // Check for local echo dedup BEFORE pushing any events for + // this entry — otherwise the acp_message duplicate gets in. + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification?.params + ) { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + const text = params?.update?.content?.text; + if (text && remainingLocalEchoes.has(text)) { + remainingLocalEchoes.delete(text); + continue; + } + // User message not from this device (e.g. desktop app) + receivedExternalUserMessage = true; + } } - const acpEvent: SessionEvent = { + batchedEvents.push({ type: "acp_message", direction: entry.direction ?? "agent", ts, message: entry.notification, - }; - get()._handleEvent(taskRunId, acpEvent); + }); + + if ( + entry.type === "notification" && + (entry.notification?.method === "_posthog/turn_complete" || + entry.notification?.method === "_posthog/task_complete" || + entry.notification?.method === "_posthog/error" || + // Agent explicitly blocked on a user reply (e.g. a question + // tool invoked via requestPermission). Treat this as a + // turn boundary so the input UI unblocks — otherwise the + // user's answer would be stuck in the "queue while busy" + // path in sendPrompt. + entry.notification?.method === "_posthog/awaiting_user_input") + ) { + receivedAgentMessage = true; + } if ( entry.type === "notification" && entry.notification?.method === "session/update" && entry.notification?.params ) { - const sessionUpdateEvent: SessionEvent = { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + batchedEvents.push({ type: "session_update", ts, - notification: entry.notification - .params as SessionNotification, - }; - get()._handleEvent(taskRunId, sessionUpdateEvent); - - // Check if this is an agent message - means agent is responding - const sessionUpdate = - entry.notification?.params?.update?.sessionUpdate; - if ( - sessionUpdate === "agent_message_chunk" || - sessionUpdate === "agent_thought_chunk" - ) { + notification: params, + }); + + // agent_message (finalized, non-chunk) is a reasonable proxy + // for turn completion — it's emitted once the full response + // is assembled. Chunks and thoughts fire mid-turn and are NOT + // reliable. The proper signal is _posthog/turn_complete but + // it's not yet written to S3 logs by the server. + if (sessionUpdate === "agent_message") { receivedAgentMessage = true; } } @@ -368,23 +707,84 @@ export const useTaskSessionStore = create((set, get) => ({ } } - set((state) => ({ - sessions: { - ...state.sessions, - [taskRunId]: { - ...state.sessions[taskRunId], - processedLineCount: lines.length, - processedHashes: currentHashes, - // Clear pending state when we receive agent response - isPromptPending: receivedAgentMessage - ? false - : (state.sessions[taskRunId]?.isPromptPending ?? false), + // Determine if we should ping. If an external user message armed + // the ping in this same batch, honour it even though the store + // hasn't updated yet. + const wasAwaitingPing = + get().sessions[taskRunId]?.awaitingPing ?? false; + const shouldPingAfterBatch = + receivedAgentMessage && + (wasAwaitingPing || receivedExternalUserMessage); + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + + // Determine isPromptPending: external user message starts work, + // turn/task completion ends it. + let nextIsPromptPending = current.isPromptPending; + if (receivedExternalUserMessage) nextIsPromptPending = true; + if (receivedAgentMessage) nextIsPromptPending = false; + + // awaitingPing: arm when work starts (even from another device), + // disarm when it completes and the ping fires. + let nextAwaitingPing = current.awaitingPing; + if (receivedExternalUserMessage && !current.awaitingPing) { + nextAwaitingPing = true; + } + if (receivedAgentMessage) nextAwaitingPing = false; + + // Clear awaitingAgentOutput once a visibly-rendered event arrives + // (agent message, thought, tool call) — not just any non-user event. + const visibleSessionUpdates = new Set([ + "agent_message_chunk", + "agent_message", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + ]); + const hasVisibleAgentOutput = batchedEvents.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as SessionNotification)?.update + ?.sessionUpdate; + return su !== undefined && visibleSessionUpdates.has(su); + }); + const nextAwaitingAgentOutput = + current.awaitingAgentOutput && !hasVisibleAgentOutput; + + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + events: + batchedEvents.length > 0 + ? [...current.events, ...batchedEvents] + : current.events, + processedLineCount: lines.length, + processedHashes: currentHashes, + localUserEchoes: + remainingLocalEchoes.size > 0 + ? remainingLocalEchoes + : undefined, + isPromptPending: nextIsPromptPending, + awaitingPing: nextAwaitingPing, + awaitingAgentOutput: nextAwaitingAgentOutput, + }, }, - }, - })); + }; + }); + if ( + shouldPingAfterBatch && + usePreferencesStore.getState().pingsEnabled + ) { + playMeepSound().catch(() => {}); + } } } catch (err) { logger.warn("Cloud polling error", { error: err }); + } finally { + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); } }; @@ -398,7 +798,112 @@ export const useTaskSessionStore = create((set, get) => ({ if (interval) { clearInterval(interval); cloudPollers.delete(taskRunId); + pollTicks.delete(taskRunId); logger.debug("Stopped cloud S3 polling", { taskRunId }); } }, + + _resumeCloudRun: async ( + taskId: string, + previousRunId: string, + prompt: string, + ) => { + // Fetch the latest task to pick up the branch the previous run was using — + // otherwise the backend would create a new branch and we'd lose working + // tree context. + const freshTask = await getTask(taskId); + const previousBranch = freshTask.latest_run?.branch ?? null; + + const updatedTask = await runTaskInCloud(taskId, { + branch: previousBranch, + resumeFromRunId: previousRunId, + pendingUserMessage: prompt, + }); + + const newRun = updatedTask.latest_run; + if (!newRun?.id || !newRun.log_url) { + throw new Error("Resume run was created but has no id or log_url"); + } + + // Stop polling the dead run and swap the session over to the new run id. + // Read the CURRENT session state to preserve the local echo that was + // just added in sendPrompt (the captured `session` variable in the + // caller is stale). + get()._stopCloudPolling(previousRunId); + + set((state) => { + const previousSession = state.sessions[previousRunId]; + if (!previousSession) return state; + const { [previousRunId]: _old, ...rest } = state.sessions; + return { + sessions: { + ...rest, + [newRun.id]: { + ...previousSession, + taskRunId: newRun.id, + logUrl: newRun.log_url, + status: "connected", + isPromptPending: true, + processedLineCount: 0, + processedHashes: new Set(), + awaitingPing: true, + awaitingAgentOutput: true, + }, + }, + }; + }); + + get()._startCloudPolling(newRun.id, newRun.log_url); + logger.debug("Swapped to resume run", { + taskId, + previousRunId, + newRunId: newRun.id, + }); + }, })); + +// Watch for isPromptPending transitions (true → false) and auto-send the +// next queued message. Uses setTimeout so state is fully settled before +// sendPrompt re-enters the store. +const drainInFlight = new Set(); +useTaskSessionStore.subscribe((state, prev) => { + for (const [runId, session] of Object.entries(state.sessions)) { + const prevSession = prev.sessions[runId]; + if ( + prevSession?.isPromptPending && + !session.isPromptPending && + !session.terminalStatus && + session.messageQueue?.length && + !drainInFlight.has(runId) + ) { + drainInFlight.add(runId); + setTimeout(async () => { + try { + const current = useTaskSessionStore.getState().sessions[runId]; + if (!current?.messageQueue?.length) return; + + const [next, ...rest] = current.messageQueue; + useTaskSessionStore.setState((s) => { + const sess = s.sessions[runId]; + if (!sess) return s; + return { + sessions: { + ...s.sessions, + [runId]: { + ...sess, + messageQueue: rest.length > 0 ? rest : undefined, + }, + }, + }; + }); + + await useTaskSessionStore.getState().sendPrompt(current.taskId, next); + } catch (err) { + logger.warn("Failed to send queued message", { runId, error: err }); + } finally { + drainInFlight.delete(runId); + } + }, 50); + } + } +}); diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 4c1578a4b..4ee8bb75e 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -51,9 +51,22 @@ export interface SessionNotification { status?: "pending" | "in_progress" | "completed" | "failed" | null; rawInput?: Record; rawOutput?: unknown; + entries?: PlanEntry[]; + _meta?: { + claudeCode?: { + toolName?: string; + parentToolCallId?: string; + }; + }; }; } +export interface PlanEntry { + content: string; + status: "pending" | "in_progress" | "completed"; + priority: string; +} + export interface AcpMessage { type: "acp_message"; direction: "client" | "agent"; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index fb405b1a6..307efca93 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -56,27 +56,10 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { export function convertRawEntriesToEvents( rawEntries: StoredLogEntry[], notifications: SessionNotification[], - taskDescription?: string, ): SessionEvent[] { const events: SessionEvent[] = []; let notificationIdx = 0; - if (taskDescription) { - const startTs = rawEntries[0]?.timestamp - ? new Date(rawEntries[0].timestamp).getTime() - 1 - : Date.now(); - events.push({ - type: "session_update", - ts: startTs, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }); - } - for (const entry of rawEntries) { const ts = entry.timestamp ? new Date(entry.timestamp).getTime() diff --git a/apps/mobile/src/features/tasks/utils/sounds.ts b/apps/mobile/src/features/tasks/utils/sounds.ts new file mode 100644 index 000000000..8460a0cb3 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/sounds.ts @@ -0,0 +1,23 @@ +import { Audio } from "expo-av"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const meepAsset = require("../../../../assets/sounds/meep.mp3"); + +let audioModeConfigured = false; + +export async function playMeepSound(): Promise { + if (!audioModeConfigured) { + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + }); + audioModeConfigured = true; + } + const { sound } = await Audio.Sound.createAsync(meepAsset, { + shouldPlay: true, + }); + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded && status.didJustFinish) { + sound.unloadAsync(); + } + }); +} diff --git a/apps/mobile/src/hooks/useNetworkStatus.ts b/apps/mobile/src/hooks/useNetworkStatus.ts new file mode 100644 index 000000000..fc183d7b1 --- /dev/null +++ b/apps/mobile/src/hooks/useNetworkStatus.ts @@ -0,0 +1,15 @@ +import NetInfo from "@react-native-community/netinfo"; +import { useEffect, useState } from "react"; + +export function useNetworkStatus() { + const [isConnected, setIsConnected] = useState(true); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + setIsConnected(state.isConnected ?? true); + }); + return unsubscribe; + }, []); + + return { isConnected }; +} diff --git a/apps/mobile/src/lib/syntax-highlight.ts b/apps/mobile/src/lib/syntax-highlight.ts new file mode 100644 index 000000000..60f46902e --- /dev/null +++ b/apps/mobile/src/lib/syntax-highlight.ts @@ -0,0 +1,192 @@ +import hljs from "highlight.js/lib/core"; +import cpp from "highlight.js/lib/languages/cpp"; +import css from "highlight.js/lib/languages/css"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import markdown from "highlight.js/lib/languages/markdown"; +import php from "highlight.js/lib/languages/php"; +import python from "highlight.js/lib/languages/python"; +import ruby from "highlight.js/lib/languages/ruby"; +import rust from "highlight.js/lib/languages/rust"; +import scss from "highlight.js/lib/languages/scss"; +import shell from "highlight.js/lib/languages/shell"; +import sql from "highlight.js/lib/languages/sql"; +import swift from "highlight.js/lib/languages/swift"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; + +hljs.registerLanguage("cpp", cpp); +hljs.registerLanguage("c", cpp); +hljs.registerLanguage("css", css); +hljs.registerLanguage("go", go); +hljs.registerLanguage("golang", go); +hljs.registerLanguage("java", java); +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("js", javascript); +hljs.registerLanguage("jsx", javascript); +hljs.registerLanguage("json", json); +hljs.registerLanguage("markdown", markdown); +hljs.registerLanguage("md", markdown); +hljs.registerLanguage("php", php); +hljs.registerLanguage("python", python); +hljs.registerLanguage("py", python); +hljs.registerLanguage("ruby", ruby); +hljs.registerLanguage("rb", ruby); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("rs", rust); +hljs.registerLanguage("scss", scss); +hljs.registerLanguage("sass", scss); +hljs.registerLanguage("shell", shell); +hljs.registerLanguage("bash", shell); +hljs.registerLanguage("sh", shell); +hljs.registerLanguage("zsh", shell); +hljs.registerLanguage("sql", sql); +hljs.registerLanguage("swift", swift); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("ts", typescript); +hljs.registerLanguage("tsx", typescript); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("html", xml); +hljs.registerLanguage("svg", xml); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("yml", yaml); + +export interface HighlightSegment { + text: string; + className?: string; +} + +// One Dark palette matching the desktop app +const ONE_DARK_COLORS: Record = { + "hljs-keyword": "#c678dd", + "hljs-built_in": "#e5c07b", + "hljs-type": "#e5c07b", + "hljs-literal": "#d19a66", + "hljs-number": "#d19a66", + "hljs-string": "#98c379", + "hljs-regexp": "#56b6c2", + "hljs-comment": "#8a8275", + "hljs-doctag": "#c678dd", + "hljs-function": "#61afef", + "hljs-title": "#61afef", + "hljs-title.function_": "#61afef", + "hljs-params": "#c4baa8", + "hljs-variable": "#e06c75", + "hljs-attr": "#d19a66", + "hljs-attribute": "#d19a66", + "hljs-name": "#e06c75", + "hljs-tag": "#e06c75", + "hljs-selector-tag": "#e06c75", + "hljs-selector-class": "#e5c07b", + "hljs-selector-id": "#61afef", + "hljs-property": "#e06c75", + "hljs-meta": "#56b6c2", + "hljs-operator": "#56b6c2", + "hljs-punctuation": "#c4baa8", + "hljs-subst": "#c4baa8", + "hljs-symbol": "#56b6c2", + "hljs-addition": "#98c379", + "hljs-deletion": "#e06c75", +}; + +function decodeEntities(text: string): string { + // Decode & last to avoid double-unescaping (e.g. &lt; → < → <) + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +/** + * Parse highlight.js HTML output into segments. + * Input: `const x = 42;` + */ +function parseHljsHtml(html: string): HighlightSegment[] { + const segments: HighlightSegment[] = []; + const regex = /([\s\S]*?)<\/span>|([^<]+)/g; + + for (let match = regex.exec(html); match !== null; match = regex.exec(html)) { + if (match[3]) { + segments.push({ text: decodeEntities(match[3]) }); + } else if (match[1] && match[2] !== undefined) { + const className = match[1]; + const inner = match[2]; + if (inner.includes(" = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + go: "go", + rs: "rust", + rb: "ruby", + java: "java", + c: "c", + cpp: "cpp", + h: "cpp", + hpp: "cpp", + css: "css", + scss: "scss", + html: "html", + xml: "xml", + svg: "xml", + json: "json", + yaml: "yaml", + yml: "yaml", + md: "markdown", + sql: "sql", + sh: "shell", + bash: "shell", + zsh: "shell", + swift: "swift", + php: "php", +}; + +export function languageFromPath(filePath: string): string | null { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return null; + return EXT_TO_LANGUAGE[ext] ?? null; +} diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index 3cfeba297..722c3fab9 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -25,6 +25,14 @@ export const POSTHOG_NOTIFICATIONS = { /** Agent finished processing a turn (prompt returned, waiting for next input) */ TURN_COMPLETE: "_posthog/turn_complete", + /** + * Agent has stopped mid-turn and is blocked on a user reply (e.g. a + * question tool invoked via requestPermission). Emitted by permission + * handlers before they block; clients use it to unblock their input UI + * so the user's answer is sent directly instead of being queued. + */ + AWAITING_USER_INPUT: "_posthog/awaiting_user_input", + /** Error occurred during task execution */ ERROR: "_posthog/error", diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index b97ab8fdb..6315b22d8 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -3,6 +3,7 @@ import type { RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; +import { POSTHOG_NOTIFICATIONS } from "../../../acp-extensions"; import { text } from "../../../utils/acp-content"; import type { Logger } from "../../../utils/logger"; import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp"; @@ -276,6 +277,25 @@ async function handleAskUserQuestionTool( input: toolInput, }); + // Tell any attached clients (including log readers like the mobile app) + // that the agent has stopped and is blocked on a user reply. Without this + // signal, clients reading the log-only stream have no reliable way to + // distinguish "tool running" from "tool waiting for a human" — and treat + // replies as queued-while-busy instead of sending them through. + try { + await client.extNotification(POSTHOG_NOTIFICATIONS.AWAITING_USER_INPUT, { + sessionId, + toolCallId: toolUseID, + }); + } catch (err) { + context.logger.warn( + "[AskUserQuestion] Failed to emit awaiting_user_input", + { + error: err, + }, + ); + } + const response = await client.requestPermission({ options, sessionId, @@ -291,10 +311,23 @@ async function handleAskUserQuestionTool( }, }); - if (context.signal?.aborted || response.outcome?.outcome === "cancelled") { + if (context.signal?.aborted) { throw new Error("Tool use aborted"); } + if (response.outcome?.outcome === "cancelled") { + const cancelMessage = ( + response._meta as Record | undefined + )?.message; + return { + behavior: "deny", + message: + typeof cancelMessage === "string" + ? cancelMessage + : "User cancelled the questions", + }; + } + if (response.outcome?.outcome !== "selected") { const customMessage = ( response._meta as Record | undefined diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 69f9dd620..8af63b094 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -599,6 +599,38 @@ export class AgentServer { }; } + case "permission_response": { + // Cloud questions are not a blocking permission wait (the cloud + // permission handler returns "cancelled" with a wait hint so + // Claude ends its turn, then resumes on the next user_message). + // So a mobile permission_response for a cloud run is effectively + // a follow-up prompt — forward it through the user_message path + // using the user's answer as prompt text. + const customInput = + typeof params.customInput === "string" ? params.customInput : ""; + const rawAnswers = params.answers; + const answerValues = + rawAnswers && + typeof rawAnswers === "object" && + !Array.isArray(rawAnswers) + ? Object.values(rawAnswers as Record).filter( + (v): v is string => typeof v === "string", + ) + : []; + const answerText = customInput || answerValues.join(", "); + + if (!answerText) { + this.logger.warn("permission_response missing answer content", { + toolCallId: params.toolCallId, + }); + return { stopReason: "cancelled" }; + } + + return await this.executeCommand("user_message", { + content: answerText, + }); + } + case POSTHOG_NOTIFICATIONS.CANCEL: case "cancel": { this.logger.info("Cancel requested", { @@ -636,8 +668,7 @@ export class AgentServer { }; } - case POSTHOG_NOTIFICATIONS.PERMISSION_RESPONSE: - case "permission_response": { + case POSTHOG_NOTIFICATIONS.PERMISSION_RESPONSE: { const requestId = params.requestId as string; const optionId = params.optionId as string; const customInput = params.customInput as string | undefined; @@ -1579,6 +1610,88 @@ ${attributionInstructions} } } + // Interactive sessions (mobile/web): don't auto-approve questions. + // Log the question as in-progress so the client renders it interactively, + // then return cancelled with a wait message so the agent waits for the + // user's reply (same pattern as the Slack relay above). + if ( + mode === "interactive" && + codeToolKind === "question" && + interactionOrigin !== "slack" && + params.toolCall?._meta + ) { + const questionMeta = params.toolCall._meta; + const toolCallId = `question-${Date.now()}`; + + const questionNotification = { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + title: "AskUserQuestion", + toolCallId, + status: "in_progress", + rawInput: questionMeta, + }, + }, + }; + + this.broadcastEvent({ + type: "notification", + timestamp: new Date().toISOString(), + notification: questionNotification, + }); + + this.session?.logWriter.appendRawLine( + payload.run_id, + JSON.stringify(questionNotification), + ); + + return { + outcome: { outcome: "cancelled" as const }, + _meta: { + message: + "This question has been sent to the user's device. " + + "The user will reply with their selection. Do NOT re-ask the question or pick an answer yourself. " + + "Wait for the user's reply.", + }, + }; + } + + // Background mode or non-question permissions: log as completed and auto-approve + if (codeToolKind === "question" && params.toolCall?._meta) { + const questionMeta = params.toolCall._meta; + const toolCallId = `question-${Date.now()}`; + const selectedOption = allowOption?.name ?? "Auto-approved"; + + const questionNotification = { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + title: "AskUserQuestion", + toolCallId, + status: "completed", + rawInput: questionMeta, + rawOutput: { answer: selectedOption }, + }, + }, + }; + + this.broadcastEvent({ + type: "notification", + timestamp: new Date().toISOString(), + notification: questionNotification, + }); + + this.session?.logWriter.appendRawLine( + payload.run_id, + JSON.stringify(questionNotification), + ); + } + // Relay permission requests to the desktop app when: // - Questions: always relay (need human answers regardless of mode) // - Plan approvals: always relay @@ -1953,18 +2066,27 @@ ${attributionInstructions} private broadcastTurnComplete(stopReason: string): void { if (!this.session) return; + const notification = { + jsonrpc: "2.0" as const, + method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE, + params: { + sessionId: this.session.acpSessionId, + stopReason, + }, + }; + this.broadcastEvent({ type: "notification", timestamp: new Date().toISOString(), - notification: { - jsonrpc: "2.0", - method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE, - params: { - sessionId: this.session.acpSessionId, - stopReason, - }, - }, + notification, }); + + // Persist to S3 log so mobile clients (which poll S3) can detect + // turn completion and trigger "your turn" indicators / sounds. + this.session.logWriter.appendRawLine( + this.session.payload.run_id, + JSON.stringify(notification), + ); } private broadcastEvent(event: Record): void { diff --git a/packages/agent/src/server/question-relay.test.ts b/packages/agent/src/server/question-relay.test.ts index e3865e309..8f571b437 100644 --- a/packages/agent/src/server/question-relay.test.ts +++ b/packages/agent/src/server/question-relay.test.ts @@ -225,7 +225,7 @@ describe("Question relay", () => { delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); - it("auto-approves question tools (no Slack relay)", async () => { + it("returns cancelled with wait message for interactive question tools", async () => { const client = server.createCloudClient(TEST_PAYLOAD); const result = await client.requestPermission({ @@ -233,7 +233,8 @@ describe("Question relay", () => { toolCall: { _meta: QUESTION_META }, }); - expect(result.outcome.outcome).toBe("selected"); + expect(result.outcome.outcome).toBe("cancelled"); + expect(result._meta?.message).toContain("sent to the user's device"); }); it("keeps auto-approving permissions after SSE send failures", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2364f028..1b814478c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: '@react-native-async-storage/async-storage': specifier: ^2.2.0 version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) + '@react-native-community/netinfo': + specifier: ^12.0.1 + version: 12.0.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) '@tanstack/react-query': specifier: ^5.90.12 version: 5.90.20(react@19.1.0) @@ -530,6 +533,9 @@ importers: expo-av: specifier: ~16.0.8 version: 16.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-clipboard: + specifier: ^55.0.13 + version: 55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.11 version: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) @@ -551,6 +557,9 @@ importers: expo-glass-effect: specifier: ~0.1.8 version: 0.1.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@54.0.33) expo-linear-gradient: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) @@ -566,6 +575,9 @@ importers: expo-secure-store: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33) + expo-speech-recognition: + specifier: ^3.1.2 + version: 3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-splash-screen: specifier: ~31.0.12 version: 31.0.13(expo@54.0.33) @@ -578,6 +590,9 @@ importers: expo-web-browser: specifier: ^15.0.10 version: 15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 nativewind: specifier: ^4.2.1 version: 4.2.1(react-native-reanimated@4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) @@ -4373,6 +4388,12 @@ packages: peerDependencies: react-native: ^0.0.0-0 || >=0.65 <1.0 + '@react-native-community/netinfo@12.0.1': + resolution: {integrity: sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==} + peerDependencies: + react: '*' + react-native: '>=0.59' + '@react-native/assets-registry@0.81.5': resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==} engines: {node: '>= 20.19.4'} @@ -6870,6 +6891,13 @@ packages: react-native-web: optional: true + expo-clipboard@55.0.13: + resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.13: resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} peerDependencies: @@ -6926,6 +6954,11 @@ packages: react: '*' react-native: '*' + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + expo-json-utils@0.15.0: resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==} @@ -7012,6 +7045,13 @@ packages: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} + expo-speech-recognition@3.1.2: + resolution: {integrity: sha512-yaXy+6w218Urdshits2KsfLjXNCnGNlXzUxEP4BVehKEbiIPAeUKBzuicCeELU5H2zTLwL9u+RjbFAUom4LiYQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-splash-screen@31.0.13: resolution: {integrity: sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==} peerDependencies: @@ -7554,6 +7594,10 @@ packages: hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono@4.11.7: resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} engines: {node: '>=16.9.0'} @@ -15946,6 +15990,11 @@ snapshots: merge-options: 3.0.4 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + '@react-native-community/netinfo@12.0.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)': + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + '@react-native/assets-registry@0.81.5': {} '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': @@ -18638,6 +18687,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-clipboard@55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-constants@18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 @@ -18704,6 +18759,10 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-haptics@55.0.14(expo@54.0.33): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-json-utils@0.15.0: {} expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): @@ -18802,6 +18861,12 @@ snapshots: expo-server@1.0.5: {} + expo-speech-recognition@3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-splash-screen@31.0.13(expo@54.0.33): dependencies: '@expo/prebuild-config': 54.0.8(expo@54.0.33) @@ -19495,6 +19560,8 @@ snapshots: dependencies: hermes-estree: 0.32.0 + highlight.js@11.11.1: {} + hono@4.11.7: {} hosted-git-info@2.8.9: {} @@ -21641,7 +21708,7 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 path-scurry@2.0.1: dependencies: From 71b78fc9bd5a1aba3f99980639ee5def1c433d7d Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 14:21:32 +0100 Subject: [PATCH 19/39] WIP - readded user filter, added more hapctics and improved markdown rendering --- apps/mobile/app.json | 4 +- .../features/chat/components/HumanMessage.tsx | 5 +- .../features/chat/components/MarkdownText.tsx | 78 ++++++++++++++++--- .../tasks/components/SwipeableTaskItem.tsx | 21 +++-- .../src/features/tasks/hooks/useTasks.ts | 1 + 5 files changed, 84 insertions(+), 25 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index beb71e880..fef07227a 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -16,7 +16,7 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.posthog.code", + "bundleIdentifier": "com.posthog.code.mobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", @@ -30,7 +30,7 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.posthog.mobile", + "package": "com.posthog.code.mobile", "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS" diff --git a/apps/mobile/src/features/chat/components/HumanMessage.tsx b/apps/mobile/src/features/chat/components/HumanMessage.tsx index 2e2ce0a75..6a70870a7 100644 --- a/apps/mobile/src/features/chat/components/HumanMessage.tsx +++ b/apps/mobile/src/features/chat/components/HumanMessage.tsx @@ -2,6 +2,7 @@ import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; import { useCallback } from "react"; import { Alert, Pressable, Text, View } from "react-native"; +import { MarkdownText } from "./MarkdownText"; interface HumanMessageProps { content: string; @@ -32,9 +33,7 @@ export function HumanMessage({ content, timestamp }: HumanMessageProps) { - - {content} - + {timestamp && ( diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index 1bc39a79d..0ce7301be 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { ScrollView, Text, View } from "react-native"; +import { Linking, ScrollView, Text, View } from "react-native"; import { getColorForClass, highlightCode } from "@/lib/syntax-highlight"; import { useThemeColors } from "@/lib/theme"; @@ -45,7 +45,14 @@ function HighlightedCode({ } interface Block { - type: "paragraph" | "code" | "heading" | "list" | "table"; + type: + | "paragraph" + | "code" + | "heading" + | "list" + | "table" + | "blockquote" + | "hr"; content: string; language?: string; level?: number; @@ -88,6 +95,24 @@ function parseBlocks(text: string): Block[] { continue; } + // Horizontal rule (---, ***, ___ with optional spaces, 3+ chars) + if (/^([-*_])\s*\1\s*\1[\s1]*$/.test(line)) { + blocks.push({ type: "hr", content: "" }); + i++; + continue; + } + + // Blockquote (consecutive > lines) + if (/^>\s?/.test(line)) { + const quoteLines: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { + quoteLines.push(lines[i].replace(/^>\s?/, "")); + i++; + } + blocks.push({ type: "blockquote", content: quoteLines.join("\n") }); + continue; + } + // Unordered list (consecutive - or * lines) if (/^\s*[-*]\s/.test(line)) { const items: string[] = []; @@ -150,6 +175,8 @@ function parseBlocks(text: string): Block[] { !lines[i].match(/^#{1,6}\s/) && !/^\s*[-*]\s/.test(lines[i]) && !/^\s*\d+[.)]\s/.test(lines[i]) && + !/^>\s?/.test(lines[i]) && + !/^([-*_])\s*\1\s*\1[\s1]*$/.test(lines[i]) && !( lines[i].includes("|") && i + 1 < lines.length && @@ -167,9 +194,15 @@ function parseBlocks(text: string): Block[] { return blocks; } +function openUrl(url: string) { + Linking.openURL(url); +} + function renderInline(text: string): React.ReactNode[] { const nodes: React.ReactNode[] = []; - const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; + // Links must come first to avoid bold/italic consuming text inside [] + const pattern = + /(\[([^\]]+)\]\(([^)]+)\)|\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; let lastIndex = 0; let match: RegExpExecArray | null = null; @@ -179,25 +212,40 @@ function renderInline(text: string): React.ReactNode[] { nodes.push(text.slice(lastIndex, match.index)); } - if (match[2]) { + if (match[2] && match[3]) { + // Link: [text](url) + const url = match[3]; nodes.push( - + openUrl(url)} + > {match[2]} , ); - } else if (match[3]) { + } else if (match[4]) { + // Bold + nodes.push( + + {match[4]} + , + ); + } else if (match[5]) { + // Italic nodes.push( - {match[3]} + {match[5]} , ); - } else if (match[4]) { + } else if (match[6]) { + // Inline code nodes.push( - {match[4]} + {match[6]} , ); } @@ -359,6 +407,18 @@ export function MarkdownText({ content }: MarkdownTextProps) { ); } + case "blockquote": + return ( + + + {renderInline(block.content)} + + + ); + + case "hr": + return ; + default: return ( diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx index 256fdbcb8..0b129f109 100644 --- a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -1,3 +1,4 @@ +import * as Haptics from "expo-haptics"; import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; import { useEffect, useRef } from "react"; import { @@ -41,7 +42,7 @@ export function SwipeableTaskItem({ useEffect(() => { translateX.setValue(0); actionTriggeredRef.current = false; - }, [isArchived, translateX]); + }, [translateX]); const panResponder = useRef( PanResponder.create({ @@ -63,20 +64,18 @@ export function SwipeableTaskItem({ actionTriggeredRef.current = false; onSwipeStart?.(); }, - onPanResponderMove: Animated.event( - [null, { dx: translateX }], - { - useNativeDriver: false, - listener: (_: unknown, gesture: { dx: number }) => { - // Clamp to left-only - if (gesture.dx > 0) translateX.setValue(0); - }, + onPanResponderMove: Animated.event([null, { dx: translateX }], { + useNativeDriver: false, + listener: (_: unknown, gesture: { dx: number }) => { + // Clamp to left-only + if (gesture.dx > 0) translateX.setValue(0); }, - ), + }), onPanResponderRelease: (_, gesture) => { onSwipeEnd?.(); if (gesture.dx < -SWIPE_THRESHOLD && !actionTriggeredRef.current) { actionTriggeredRef.current = true; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Animated.timing(translateX, { toValue: -400, duration: 150, @@ -119,7 +118,7 @@ export function SwipeableTaskItem({ {/* Action revealed behind the row */} diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 63727f1d8..e807e7161 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -27,6 +27,7 @@ export function useTasks(filters?: { repository?: string }) { const queryFilters = { ...filters, + createdBy: currentUser?.id, }; const query = useQuery({ From 2795bf4a1ac98e57f185e5ff12f17c59654a4e9e Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 14:26:34 +0100 Subject: [PATCH 20/39] WIP - Add access for photo library things --- apps/mobile/app.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index fef07227a..7128cb2b2 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -20,6 +20,7 @@ "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", + "NSPhotoLibraryUsageDescription": "Allow PostHog to access your photos for attaching images to tasks", "ITSAppUsesNonExemptEncryption": false } }, From f3ef56f9cdbad9f9df65296b523277b717e3e02d Mon Sep 17 00:00:00 2001 From: Sandy Spicer Date: Thu, 16 Apr 2026 14:34:44 +0100 Subject: [PATCH 21/39] subscription resuem --- .../services/agent/local-command-receiver.ts | 48 +- .../src/main/services/agent/service.test.ts | 13 +- apps/code/src/main/services/agent/service.ts | 803 ++++++++++-------- apps/code/src/main/trpc/routers/agent.ts | 15 + apps/code/src/main/utils/store.ts | 13 + .../src/renderer/components/MainLayout.tsx | 2 + .../hooks/useBackgroundSubscriptions.ts | 96 +++ .../src/features/chat/components/Composer.tsx | 24 +- .../tasks/components/SwipeableTaskItem.tsx | 19 +- .../features/tasks/components/TaskList.tsx | 4 +- 10 files changed, 626 insertions(+), 411 deletions(-) create mode 100644 apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts diff --git a/apps/code/src/main/services/agent/local-command-receiver.ts b/apps/code/src/main/services/agent/local-command-receiver.ts index 8a3b1d186..ff3ed152f 100644 --- a/apps/code/src/main/services/agent/local-command-receiver.ts +++ b/apps/code/src/main/services/agent/local-command-receiver.ts @@ -1,15 +1,12 @@ import { inject, injectable, preDestroy } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; +import { localCommandCursorStore } from "../../utils/store"; import type { AuthService } from "../auth/service"; const log = logger.scope("local-command-receiver"); const INITIAL_RECONNECT_DELAY_MS = 2000; const MAX_RECONNECT_DELAY_MS = 30_000; -// After this many consecutive failures, assume Last-Event-ID is stale (event -// trimmed from the backend buffer) and fall back to a fresh connect with -// start=latest. Accepts that we may drop commands issued during the outage. -const STALE_EVENT_ID_THRESHOLD = 3; /** * JSON-RPC envelope carried inside an `incoming_command` SSE event. The @@ -94,7 +91,12 @@ export class LocalCommandReceiver { params: SubscribeParams, controller: AbortController, ): Promise { - let lastEventId: string | undefined; + // Seed from persisted cursor so we resume without replay across reconnects + // AND across app restarts. On first-ever subscribe the cursor is undefined; + // we send no start param and no Last-Event-ID, which lets the backend read + // from the beginning of the stream. That is intentional — it catches + // mobile-originated commands published while this desktop was offline. + let lastEventId = this.loadCursor(params.taskRunId); let consecutiveFailures = 0; while (!controller.signal.aborted) { @@ -104,11 +106,6 @@ export class LocalCommandReceiver { const url = new URL( `${params.apiHost}/api/projects/${params.projectId}/tasks/${params.taskId}/runs/${params.taskRunId}/stream/`, ); - if (!lastEventId) { - // Fresh connect: only care about events published from now on. - // On reconnect we use Last-Event-ID instead (see headers below). - url.searchParams.set("start", "latest"); - } const headers: Record = { Authorization: `Bearer ${accessToken}`, @@ -134,6 +131,7 @@ export class LocalCommandReceiver { params.onCommand, controller.signal, lastEventId, + params.taskRunId, ); log.info("SSE stream ended cleanly", { taskRunId: params.taskRunId, @@ -141,19 +139,6 @@ export class LocalCommandReceiver { } catch (err) { if (controller.signal.aborted) return; if (!streamOpened) consecutiveFailures++; - if ( - consecutiveFailures >= STALE_EVENT_ID_THRESHOLD && - lastEventId !== undefined - ) { - log.warn( - "Dropping possibly-stale Last-Event-ID after repeated failures", - { - taskRunId: params.taskRunId, - consecutiveFailures, - }, - ); - lastEventId = undefined; - } log.warn("SSE disconnected, will reconnect", { taskRunId: params.taskRunId, consecutiveFailures, @@ -169,11 +154,23 @@ export class LocalCommandReceiver { } } + private loadCursor(taskRunId: string): string | undefined { + const cursors = localCommandCursorStore.get("cursors"); + return cursors[taskRunId]; + } + + private saveCursor(taskRunId: string, eventId: string): void { + const cursors = localCommandCursorStore.get("cursors"); + cursors[taskRunId] = eventId; + localCommandCursorStore.set("cursors", cursors); + } + private async readEventStream( body: ReadableStream | null, onCommand: SubscribeParams["onCommand"], signal: AbortSignal, seedLastEventId: string | undefined, + taskRunId: string, ): Promise { if (!body) throw new Error("Missing SSE response body"); const reader = body.getReader(); @@ -233,7 +230,10 @@ export class LocalCommandReceiver { } } - if (eventId) lastEventId = eventId; + if (eventId) { + lastEventId = eventId; + this.saveCursor(taskRunId, eventId); + } } } return lastEventId; diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 8cc5789f7..0a853cbbf 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -543,7 +543,7 @@ describe("AgentService", () => { expect(promptSpy).not.toHaveBeenCalled(); }); - it("unsubscribes on session cleanup", async () => { + it("keeps the subscription alive after session cleanup so mobile can wake a new session", async () => { await service.startSession({ ...baseSessionParams, runMode: "local", @@ -555,6 +555,17 @@ describe("AgentService", () => { } ).cleanupSession("run-1"); + expect(deps.localCommandReceiver.unsubscribe).not.toHaveBeenCalled(); + }); + + it("removeBackgroundSubscription tears down the SSE subscription", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + service.removeBackgroundSubscription("run-1"); + expect(deps.localCommandReceiver.unsubscribe).toHaveBeenCalledWith( "run-1", ); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index ad2cadd40..6ba0270ad 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -51,7 +51,10 @@ import type { ProcessTrackingService } from "../process-tracking/service"; import type { SleepService } from "../sleep/service"; import type { AgentAuthAdapter } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; -import type { LocalCommandReceiver } from "./local-command-receiver"; +import type { + IncomingCommandPayload, + LocalCommandReceiver, +} from "./local-command-receiver"; import { AgentServiceEvent, type AgentServiceEvents, @@ -316,6 +319,15 @@ export class AgentService extends TypedEventEmitter { private sessions = new Map(); private pendingPermissions = new Map(); + // Configs held for lazy-spawn when a mobile command arrives on a task run + // whose session isn't currently active. Populated by the renderer via + // ensureBackgroundSubscription for each active local task run, so the + // desktop can wake up on mobile activity without the chat being open. + private backgroundSubscriptionConfigs = new Map(); + // Tracks in-flight getOrCreateSession calls per taskRunId so concurrent + // callers (e.g. renderer.reconnect racing an incoming mobile command) + // piggyback on the same attempt instead of killing each other's agents. + private creatingSessions = new Map>(); private mockNodeReady = false; private idleTimeouts = new Map< string, @@ -558,7 +570,27 @@ When creating pull requests, add the following footer at the end of the PR descr private async getOrCreateSession( config: SessionConfig, isReconnect: boolean, - isRetry = false, + ): Promise { + const { taskRunId } = config; + const existing = this.sessions.get(taskRunId); + if (existing) return existing; + const inFlight = this.creatingSessions.get(taskRunId); + if (inFlight) return inFlight; + + const promise = this.createSessionWork(config, isReconnect); + this.creatingSessions.set(taskRunId, promise); + try { + return await promise; + } finally { + if (this.creatingSessions.get(taskRunId) === promise) { + this.creatingSessions.delete(taskRunId); + } + } + } + + private async createSessionWork( + config: SessionConfig, + isReconnect: boolean, ): Promise { const { taskId, @@ -578,304 +610,331 @@ When creating pull requests, add the following footer at the end of the PR descr // Preview config doesn't need a real repo — use a temp directory const repoPath = taskId === "__preview__" ? tmpdir() : rawRepoPath; - if (!isRetry) { - const existing = this.sessions.get(taskRunId); - if (existing) { - return existing; - } - - for (const proc of this.processTracking.getByTaskId(taskId)) { - if ( - (proc.category === "agent" || proc.category === "child") && - proc.metadata?.taskRunId === taskRunId - ) { - this.processTracking.kill(proc.pid); + // Mutable state for the retry/fallback loop. isReconnect is reassigned + // when we fall back to a fresh session after a failed reconnect; isRetry + // flips to true after an auth retry so we don't kill/clean up twice. + let isRetry = false; + + // Loop handles two recoverable failure modes: + // - auth error → retry once with isRetry=true (skips kill/cleanup). + // - reconnect failed for a non-auth reason → fall back to a fresh + // session (isReconnect=false, isRetry=false → re-runs kill/cleanup). + // Any other failure exits the loop. + while (true) { + if (!isRetry) { + for (const proc of this.processTracking.getByTaskId(taskId)) { + if ( + (proc.category === "agent" || proc.category === "child") && + proc.metadata?.taskRunId === taskRunId + ) { + this.processTracking.kill(proc.pid); + } } - } - // Clean up any prior session for this taskRunId before creating a new one - await this.cleanupSession(taskRunId); - } + // Clean up any prior session for this taskRunId before creating a new one + await this.cleanupSession(taskRunId); + } - const channel = `agent-event:${taskRunId}`; - const mockNodeDir = this.setupMockNodeEnvironment(); - const proxyUrl = await this.agentAuthAdapter.ensureGatewayProxy( - credentials.apiHost, - ); - await this.agentAuthAdapter.configureProcessEnv({ - credentials, - mockNodeDir, - proxyUrl, - claudeCliPath: getClaudeCliPath(), - }); + const channel = `agent-event:${taskRunId}`; + const mockNodeDir = this.setupMockNodeEnvironment(); + const proxyUrl = await this.agentAuthAdapter.ensureGatewayProxy( + credentials.apiHost, + ); + await this.agentAuthAdapter.configureProcessEnv({ + credentials, + mockNodeDir, + proxyUrl, + claudeCliPath: getClaudeCliPath(), + }); - const isPreview = taskId === "__preview__"; + const isPreview = taskId === "__preview__"; - const agent = new Agent({ - posthog: { - ...this.agentAuthAdapter.createPosthogConfig(credentials), - userAgent: `posthog/desktop.hog.dev; version: ${app.getVersion()}`, - }, - skipLogPersistence: isPreview, - localCachePath: join(app.getPath("home"), ".posthog-code"), - debug: isDevBuild(), - onLog: onAgentLog, - }); + const agent = new Agent({ + posthog: { + ...this.agentAuthAdapter.createPosthogConfig(credentials), + userAgent: `posthog/desktop.hog.dev; version: ${app.getVersion()}`, + }, + skipLogPersistence: isPreview, + localCachePath: join(app.getPath("home"), ".posthog-code"), + debug: isDevBuild(), + onLog: onAgentLog, + }); - try { - const systemPrompt = this.buildSystemPrompt( - credentials, - taskId, - customInstructions, - ); + try { + const systemPrompt = this.buildSystemPrompt( + credentials, + taskId, + customInstructions, + ); - const acpConnection = await agent.run(taskId, taskRunId, { - adapter, - gatewayUrl: proxyUrl, - codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined, - model, - instructions: adapter === "codex" ? systemPrompt.append : undefined, - onStructuredOutput: jsonSchema - ? async (output) => { - const posthogAPI = agent.getPosthogAPI(); - if (posthogAPI) { - await posthogAPI.updateTaskRun(taskId, taskRunId, { output }); + const acpConnection = await agent.run(taskId, taskRunId, { + adapter, + gatewayUrl: proxyUrl, + codexBinaryPath: + adapter === "codex" ? getCodexBinaryPath() : undefined, + model, + instructions: adapter === "codex" ? systemPrompt.append : undefined, + onStructuredOutput: jsonSchema + ? async (output) => { + const posthogAPI = agent.getPosthogAPI(); + if (posthogAPI) { + await posthogAPI.updateTaskRun(taskId, taskRunId, { output }); + } } - } - : undefined, - processCallbacks: { - onProcessSpawned: (info) => { - this.processTracking.register( - info.pid, - "agent", - `agent:${taskRunId}`, - { - taskRunId, + : undefined, + processCallbacks: { + onProcessSpawned: (info) => { + this.processTracking.register( + info.pid, + "agent", + `agent:${taskRunId}`, + { + taskRunId, + taskId, + command: info.command, + }, taskId, - command: info.command, - }, - taskId, - ); - }, - onProcessExited: (pid) => { - this.processTracking.unregister(pid, "agent-exited"); - }, - onMcpServersReady: (serverNames) => { - this.mcpAppsService.handleDiscovery(serverNames).catch((err) => { - log.warn("MCP Apps discovery failed", { - error: err instanceof Error ? err.message : String(err), + ); + }, + onProcessExited: (pid) => { + this.processTracking.unregister(pid, "agent-exited"); + }, + onMcpServersReady: (serverNames) => { + this.mcpAppsService.handleDiscovery(serverNames).catch((err) => { + log.warn("MCP Apps discovery failed", { + error: err instanceof Error ? err.message : String(err), + }); }); - }); + }, }, - }, - }); - const { clientStreams } = acpConnection; + }); + const { clientStreams } = acpConnection; - const connection = this.createClientConnection( - taskRunId, - channel, - clientStreams, - ); + const connection = this.createClientConnection( + taskRunId, + channel, + clientStreams, + ); - await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, + await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, }, - terminal: true, - }, - }); + }); - const mcpServers = - await this.agentAuthAdapter.buildMcpServers(credentials); - - // Store server configs for lazy MCP connections — actual connections - // are created on-demand when UI resources are first requested. - this.mcpAppsService.setServerConfigs( - mcpServers.map((s) => ({ - name: s.name, - url: s.url, - headers: Object.fromEntries(s.headers.map((h) => [h.name, h.value])), - })), - ); + const mcpServers = + await this.agentAuthAdapter.buildMcpServers(credentials); + + // Store server configs for lazy MCP connections — actual connections + // are created on-demand when UI resources are first requested. + this.mcpAppsService.setServerConfigs( + mcpServers.map((s) => ({ + name: s.name, + url: s.url, + headers: Object.fromEntries( + s.headers.map((h) => [h.name, h.value]), + ), + })), + ); - let externalPlugins: Awaited> = - []; - try { - externalPlugins = await discoverExternalPlugins({ - userDataDir: app.getPath("userData"), - repoPath, - }); - } catch (err) { - log.warn("Failed to discover external plugins", { - error: err instanceof Error ? err.message : String(err), + let externalPlugins: Awaited< + ReturnType + > = []; + try { + externalPlugins = await discoverExternalPlugins({ + userDataDir: app.getPath("userData"), + repoPath, + }); + } catch (err) { + log.warn("Failed to discover external plugins", { + error: err instanceof Error ? err.message : String(err), + }); + } + const plugins = [ + { + type: "local" as const, + path: this.posthogPluginService.getPluginPath(), + }, + ...externalPlugins, + ]; + const claudeCodeOptions = buildClaudeCodeOptions({ + additionalDirectories, + effort, + plugins, }); - } - const plugins = [ - { - type: "local" as const, - path: this.posthogPluginService.getPluginPath(), - }, - ...externalPlugins, - ]; - const claudeCodeOptions = buildClaudeCodeOptions({ - additionalDirectories, - effort, - plugins, - }); - let configOptions: SessionConfigOption[] | undefined; - let agentSessionId: string; + let configOptions: SessionConfigOption[] | undefined; + let agentSessionId: string; + + // Claude-specific: hydrate session JSONL from PostHog before resuming. + // If hydration finds no conversation to restore, skip the resume and + // fall through to creating a new session. This avoids a doomed + // unstable_resumeSession that would fail with "Resource not found" + if (isReconnect && config.sessionId) { + const existingSessionId = config.sessionId; + + if (adapter !== "codex") { + const posthogAPI = agent.getPosthogAPI(); + if (posthogAPI) { + const hasSession = await hydrateSessionJsonl({ + sessionId: existingSessionId, + cwd: repoPath, + taskId, + runId: taskRunId, + permissionMode: config.permissionMode, + posthogAPI, + log, + }); + if (!hasSession) { + log.info( + "No session JSONL to resume, creating new session instead", + { taskId, taskRunId }, + ); + config.sessionId = undefined; + } + } + } + } - // Claude-specific: hydrate session JSONL from PostHog before resuming. - // If hydration finds no conversation to restore, skip the resume and - // fall through to creating a new session. This avoids a doomed - // unstable_resumeSession that would fail with "Resource not found" - if (isReconnect && config.sessionId) { - const existingSessionId = config.sessionId; + if (isReconnect && config.sessionId) { + const existingSessionId = config.sessionId; - if (adapter !== "codex") { - const posthogAPI = agent.getPosthogAPI(); - if (posthogAPI) { - const hasSession = await hydrateSessionJsonl({ + // Both adapters implement unstable_resumeSession: + // - Claude: delegates to SDK's resumeSession with JSONL hydration + // - Codex: delegates to codex-acp's loadSession internally + const resumeResponse = await connection.unstable_resumeSession({ + sessionId: existingSessionId, + cwd: repoPath, + mcpServers, + _meta: { + ...(logUrl && { + persistence: { taskId, runId: taskRunId, logUrl }, + }), + taskRunId, sessionId: existingSessionId, - cwd: repoPath, + systemPrompt, + ...(permissionMode && { permissionMode }), + ...(model != null && { model }), + ...(jsonSchema && { jsonSchema }), + claudeCode: { + options: claudeCodeOptions, + }, + }, + }); + configOptions = resumeResponse?.configOptions ?? undefined; + agentSessionId = existingSessionId; + } else { + if (isReconnect) { + log.info("No sessionId for reconnect, creating new session", { taskId, - runId: taskRunId, - permissionMode: config.permissionMode, - posthogAPI, - log, + taskRunId, }); - if (!hasSession) { - log.info( - "No session JSONL to resume, creating new session instead", - { taskId, taskRunId }, - ); - config.sessionId = undefined; - } } - } - } - - if (isReconnect && config.sessionId) { - const existingSessionId = config.sessionId; - - // Both adapters implement unstable_resumeSession: - // - Claude: delegates to SDK's resumeSession with JSONL hydration - // - Codex: delegates to codex-acp's loadSession internally - const resumeResponse = await connection.unstable_resumeSession({ - sessionId: existingSessionId, - cwd: repoPath, - mcpServers, - _meta: { - ...(logUrl && { - persistence: { taskId, runId: taskRunId, logUrl }, - }), - taskRunId, - sessionId: existingSessionId, - systemPrompt, - ...(permissionMode && { permissionMode }), - ...(model != null && { model }), - ...(jsonSchema && { jsonSchema }), - claudeCode: { - options: claudeCodeOptions, + const newSessionResponse = await connection.newSession({ + cwd: repoPath, + mcpServers, + _meta: { + taskRunId, + systemPrompt, + ...(permissionMode && { permissionMode }), + ...(model != null && { model }), + ...(jsonSchema && { jsonSchema }), + claudeCode: { + options: claudeCodeOptions, + }, }, - }, - }); - configOptions = resumeResponse?.configOptions ?? undefined; - agentSessionId = existingSessionId; - } else { - if (isReconnect) { - log.info("No sessionId for reconnect, creating new session", { - taskId, - taskRunId, }); + configOptions = newSessionResponse.configOptions ?? undefined; + agentSessionId = newSessionResponse.sessionId; } - const newSessionResponse = await connection.newSession({ - cwd: repoPath, - mcpServers, - _meta: { - taskRunId, - systemPrompt, - ...(permissionMode && { permissionMode }), - ...(model != null && { model }), - ...(jsonSchema && { jsonSchema }), - claudeCode: { - options: claudeCodeOptions, - }, - }, - }); - configOptions = newSessionResponse.configOptions ?? undefined; - agentSessionId = newSessionResponse.sessionId; - } - config.sessionId = agentSessionId; + config.sessionId = agentSessionId; - const session: ManagedSession = { - taskRunId, - taskId, - repoPath, - agent, - clientSideConnection: connection, - channel, - createdAt: Date.now(), - lastActivityAt: Date.now(), - config, - promptPending: false, - configOptions, - inFlightMcpToolCalls: new Map(), - }; + const session: ManagedSession = { + taskRunId, + taskId, + repoPath, + agent, + clientSideConnection: connection, + channel, + createdAt: Date.now(), + lastActivityAt: Date.now(), + config, + promptPending: false, + configOptions, + inFlightMcpToolCalls: new Map(), + }; - this.sessions.set(taskRunId, session); - this.recordActivity(taskRunId); + this.sessions.set(taskRunId, session); + this.recordActivity(taskRunId); - if (config.runMode === "local") { - this.ensureLocalCommandSubscription( - taskId, - taskRunId, - credentials.projectId, - credentials.apiHost, - ); - } + if (config.runMode === "local") { + this.ensureBackgroundSubscription(config); + // Flip the backend task run out of "not_started" so the mobile UI + // (which gates connect/compose state on backend status) recognizes + // the session as live. Local tasks never go through the Temporal + // workflow that does this for cloud runs, so the desktop has to + // report status itself. Fire-and-forget. + const posthogAPI = agent.getPosthogAPI(); + if (posthogAPI) { + posthogAPI + .updateTaskRun(taskId, taskRunId, { status: "in_progress" }) + .catch((err) => { + log.warn("Failed to update task run status to in_progress", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + } - if (isRetry) { - log.info("Session created after auth retry", { taskRunId }); - } - return session; - } catch (err) { - try { - await agent.cleanup(); - } catch { - log.debug("Agent cleanup failed during error handling", { taskRunId }); - } + if (isRetry) { + log.info("Session created after auth retry", { taskRunId }); + } + return session; + } catch (err) { + try { + await agent.cleanup(); + } catch { + log.debug("Agent cleanup failed during error handling", { + taskRunId, + }); + } - if (!isRetry && isAuthError(err)) { - log.warn( - `Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`, - { taskRunId }, + if (!isRetry && isAuthError(err)) { + log.warn( + `Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`, + { taskRunId }, + ); + isRetry = true; + continue; + } + log.error( + `Failed to ${isReconnect ? "reconnect" : "create"} session${ + isRetry ? " after retry" : "" + }`, + err, ); - return this.getOrCreateSession(config, isReconnect, true); - } - log.error( - `Failed to ${isReconnect ? "reconnect" : "create"} session${ - isRetry ? " after retry" : "" - }`, - err, - ); - // Non-auth reconnect failure on first attempt: fall back to a fresh session. - // If this was already an auth retry (isRetry=true), we've exhausted retries - // and return null to avoid infinite loops. - if (isReconnect && !isRetry) { - log.warn("Reconnect failed, falling back to new session", { - taskRunId, - }); - config.sessionId = undefined; - return this.getOrCreateSession(config, false, false); + // Non-auth reconnect failure on first attempt: fall back to a fresh session. + // If this was already an auth retry (isRetry=true), we've exhausted retries + // and return null to avoid infinite loops. + if (isReconnect && !isRetry) { + log.warn("Reconnect failed, falling back to new session", { + taskRunId, + }); + config.sessionId = undefined; + isReconnect = false; + isRetry = false; + continue; + } + if (isReconnect) return null; + throw err; } - if (isReconnect) return null; - throw err; } } @@ -1180,108 +1239,140 @@ For git operations while detached: } /** - * Idempotently subscribe the LocalCommandReceiver to a task-run's SSE - * stream so mobile-originated /command/ calls reach this session. Called - * both on fresh session creation and when an existing session is reused - * — the receiver itself short-circuits duplicate subscribes, so multiple - * calls are safe. Without this, a session that existed before this code - * path shipped (or that had its subscription torn down by an earlier - * cleanup) would silently drop mobile commands. + * Subscribe to a task run's SSE stream so mobile-originated /command/ + * calls reach the desktop — including when no agent session is currently + * running. If a command arrives with no session, the stored config is + * used to lazy-spawn via getOrCreateSession(isReconnect=true) before + * dispatching. Idempotent; safe to call repeatedly. + * + * Called from (a) session creation (with the SessionConfig we just built) + * and (b) the renderer for every active local task, so "old" idle + * conversations still wake on mobile activity. */ - private ensureLocalCommandSubscription( - taskId: string, - taskRunId: string, - projectId: number, - apiHost: string, + public ensureBackgroundSubscription( + input: SessionConfig | ReconnectSessionInput, ): void { + const config: SessionConfig = + "credentials" in input ? input : this.toSessionConfig(input); + if (config.runMode !== "local") return; + this.backgroundSubscriptionConfigs.set(config.taskRunId, config); this.localCommandReceiver.subscribe({ - taskId, + taskId: config.taskId, + taskRunId: config.taskRunId, + projectId: config.credentials.projectId, + apiHost: config.credentials.apiHost, + onCommand: (payload) => + this.dispatchIncomingCommand(config.taskRunId, payload), + }); + } + + /** + * Stop watching a task run for mobile commands. Call when the task is + * terminal (completed/failed/cancelled) — otherwise we keep listening + * so mobile messages can always wake the desktop. + */ + public removeBackgroundSubscription(taskRunId: string): void { + this.backgroundSubscriptionConfigs.delete(taskRunId); + this.localCommandReceiver.unsubscribe(taskRunId); + } + + private async dispatchIncomingCommand( + taskRunId: string, + payload: IncomingCommandPayload, + ): Promise { + log.debug("Local command received", { taskRunId, - projectId, - apiHost, - onCommand: async (payload) => { - log.debug("Local command received", { + method: payload.method, + }); + + // permission_response must route directly to the pending promise — + // treating it as a new prompt would leave the agent blocked inside + // its current turn while a second turn queues behind it and never runs. + if (payload.method === "permission_response") { + const params = payload.params ?? {}; + const toolCallId = + typeof params.toolCallId === "string" ? params.toolCallId : undefined; + const optionId = + typeof params.optionId === "string" ? params.optionId : undefined; + const customInput = + typeof params.customInput === "string" ? params.customInput : undefined; + const rawAnswers = params.answers; + const answers = + rawAnswers && typeof rawAnswers === "object" + ? (rawAnswers as Record) + : undefined; + if (!toolCallId || !optionId) { + log.warn("Invalid permission_response from external client", { taskRunId, - method: payload.method, + hasToolCallId: !!toolCallId, + hasOptionId: !!optionId, }); + return; + } + try { + this.respondToPermission( + taskRunId, + toolCallId, + optionId, + customInput, + answers, + ); + } catch (err) { + log.error("Failed to apply external permission_response", { + taskRunId, + toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + } + return; + } - // Mobile (or any external client) answering an outstanding - // requestPermission call. Route it directly to the pending - // promise rather than treating it as a new prompt — otherwise - // the agent stays blocked inside the current turn and the - // answer starts a second turn that can never run. - if (payload.method === "permission_response") { - const params = payload.params ?? {}; - const toolCallId = - typeof params.toolCallId === "string" - ? params.toolCallId - : undefined; - const optionId = - typeof params.optionId === "string" ? params.optionId : undefined; - const customInput = - typeof params.customInput === "string" - ? params.customInput - : undefined; - const rawAnswers = params.answers; - const answers = - rawAnswers && typeof rawAnswers === "object" - ? (rawAnswers as Record) - : undefined; - if (!toolCallId || !optionId) { - log.warn("Invalid permission_response from external client", { - taskRunId, - hasToolCallId: !!toolCallId, - hasOptionId: !!optionId, - }); - return; - } - try { - this.respondToPermission( - taskRunId, - toolCallId, - optionId, - customInput, - answers, - ); - } catch (err) { - log.error("Failed to apply external permission_response", { - taskRunId, - toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - } - return; - } + if (payload.method !== "user_message") { + log.debug("Ignoring non-user_message local command", { + method: payload.method, + taskRunId, + }); + return; + } + const content = payload.params?.content; + if (typeof content !== "string" || content.length === 0) { + log.warn("Local command missing content", { taskRunId }); + return; + } - if (payload.method !== "user_message") { - log.debug("Ignoring non-user_message local command", { - method: payload.method, - taskRunId, - }); - return; - } - const content = payload.params?.content; - if (typeof content !== "string" || content.length === 0) { - log.warn("Local command missing content", { taskRunId }); - return; - } - try { - await this.prompt(taskRunId, [{ type: "text", text: content }]); - } catch (err) { - log.error("Failed to deliver local command to session", { - taskRunId, - error: err instanceof Error ? err.message : String(err), - }); - } - }, - }); + if (!this.sessions.has(taskRunId)) { + const config = this.backgroundSubscriptionConfigs.get(taskRunId); + if (!config) { + log.warn("Incoming user_message with no session and no stored config", { + taskRunId, + }); + return; + } + log.info("Lazy-spawning session to deliver mobile command", { + taskRunId, + }); + const session = await this.getOrCreateSession(config, true); + if (!session) { + log.error("Lazy-spawn failed; dropping mobile command", { taskRunId }); + return; + } + } + + try { + await this.prompt(taskRunId, [{ type: "text", text: content }]); + } catch (err) { + log.error("Failed to deliver local command to session", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + } } private async cleanupSession(taskRunId: string): Promise { - // Abort any outstanding SSE subscription for this run first — per - // async-cleanup-ordering guidance, we release external resources - // before awaiting anything that depends on the session being gone. - this.localCommandReceiver.unsubscribe(taskRunId); + // The LCR subscription outlives the agent session so a later mobile + // command can wake up a fresh session via lazy-spawn. Only + // removeBackgroundSubscription (called when the task goes terminal) + // tears down the SSE connection. const session = this.sessions.get(taskRunId); if (session) { diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 835845332..db6591497 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -62,6 +62,21 @@ export const agentRouter = router({ .output(sessionResponseSchema.nullable()) .mutation(({ input }) => getService().reconnectSession(input)), + // Register a task run for background mobile-command pickup. Idempotent. + // When a mobile command arrives and no session is active, the main process + // uses the stored config to lazy-spawn via reconnect before dispatching. + ensureBackgroundSubscription: publicProcedure + .input(reconnectSessionInput) + .mutation(({ input }) => { + getService().ensureBackgroundSubscription(input); + }), + + removeBackgroundSubscription: publicProcedure + .input(cancelSessionInput) + .mutation(({ input }) => { + getService().removeBackgroundSubscription(input.sessionId); + }), + setConfigOption: publicProcedure .input(setConfigOptionInput) .mutation(({ input }) => diff --git a/apps/code/src/main/utils/store.ts b/apps/code/src/main/utils/store.ts index d84d3ebab..b7e29eed2 100644 --- a/apps/code/src/main/utils/store.ts +++ b/apps/code/src/main/utils/store.ts @@ -18,6 +18,13 @@ interface RendererStoreSchema { [key: string]: string; } +interface LocalCommandCursorSchema { + // taskRunId → last SSE event ID processed from the task-run stream. + // Used by LocalCommandReceiver to resume without replay across reconnects + // and app restarts, so mobile-originated commands aren't double-delivered. + cursors: Record; +} + export interface WindowStateSchema { x: number | undefined; y: number | undefined; @@ -31,6 +38,12 @@ export const rendererStore = new Store({ cwd: app.getPath("userData"), }); +export const localCommandCursorStore = new Store({ + name: "local-command-cursor", + cwd: app.getPath("userData"), + defaults: { cursors: {} }, +}); + export const focusStore = new Store({ name: "focus", cwd: app.getPath("userData"), diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index f49e76d66..28e36e2ab 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -7,6 +7,7 @@ import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksVie import { CommandMenu } from "@features/command/components/CommandMenu"; import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; import { InboxView } from "@features/inbox/components/InboxView"; +import { useBackgroundSubscriptions } from "@features/sessions/hooks/useBackgroundSubscriptions"; import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; import { SettingsDialog } from "@features/settings/components/SettingsDialog"; import { MainSidebar } from "@features/sidebar/components/MainSidebar"; @@ -41,6 +42,7 @@ export function MainLayout() { useIntegrations(); useTaskDeepLink(); + useBackgroundSubscriptions(); useEffect(() => { if (tasks) { diff --git a/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts b/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts new file mode 100644 index 000000000..3ecf76ebd --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts @@ -0,0 +1,96 @@ +import { useAuthState } from "@features/auth/hooks/authQueries"; +import { useTasks } from "@features/tasks/hooks/useTasks"; +import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import { trpcClient } from "@renderer/trpc/client"; +import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { isTerminalStatus } from "@shared/types"; +import { logger } from "@utils/logger"; +import { useEffect, useRef } from "react"; + +const log = logger.scope("background-subscriptions"); + +/** + * Ensures the main process has an SSE subscription open for every + * non-terminal local task run the user has, so mobile-originated commands + * wake the desktop even when no chat is open and no agent session is + * active. Reconciles on every task-list refresh — enrolls newcomers, + * tears down ones that dropped off the list or went terminal. + */ +export function useBackgroundSubscriptions() { + const { data: tasks } = useTasks(); + const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); + const { data: authState } = useAuthState(); + const enrolled = useRef>(new Set()); + + useEffect(() => { + // Wait until we know the user's auth + workspaces. Without repoPath we + // can't build a valid config and lazy-spawn would fail anyway. + if (!workspacesFetched) return; + if ( + authState?.status !== "authenticated" || + !authState.cloudRegion || + !authState.projectId + ) { + return; + } + if (!tasks) return; + + const apiHost = getCloudUrlFromRegion(authState.cloudRegion); + const projectId = authState.projectId; + const desired = new Map< + string, + { taskId: string; taskRunId: string; repoPath: string; logUrl?: string } + >(); + + for (const task of tasks) { + const run = task.latest_run; + if (!run) continue; + if (run.environment !== "local") continue; + if (isTerminalStatus(run.status)) continue; + const workspace = workspaces?.[task.id]; + const repoPath = workspace?.folderPath; + if (!repoPath) continue; + desired.set(run.id, { + taskId: task.id, + taskRunId: run.id, + repoPath, + logUrl: run.log_url || undefined, + }); + } + + for (const [taskRunId, input] of desired) { + if (enrolled.current.has(taskRunId)) continue; + enrolled.current.add(taskRunId); + trpcClient.agent.ensureBackgroundSubscription + .mutate({ + taskId: input.taskId, + taskRunId, + repoPath: input.repoPath, + apiHost, + projectId, + logUrl: input.logUrl, + runMode: "local", + }) + .catch((err) => { + enrolled.current.delete(taskRunId); + log.warn("Failed to enroll background subscription", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + for (const taskRunId of Array.from(enrolled.current)) { + if (desired.has(taskRunId)) continue; + enrolled.current.delete(taskRunId); + trpcClient.agent.removeBackgroundSubscription + .mutate({ sessionId: taskRunId }) + .catch((err) => { + log.warn("Failed to remove background subscription", { + taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + }, [tasks, workspaces, workspacesFetched, authState]); +} diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index 0a1104389..a7478b69d 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -21,13 +21,7 @@ interface ComposerProps { queuedCount?: number; } -function PulsingBorder({ - active, - color, -}: { - active: boolean; - color: string; -}) { +function PulsingBorder({ active, color }: { active: boolean; color: string }) { const opacity = useRef(new Animated.Value(0)).current; const animRef = useRef(null); @@ -121,11 +115,12 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; - const effectivePlaceholder = queuedCount > 0 - ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` - : !isUserTurn && !disabled - ? "Message will be queued..." - : placeholder; + const effectivePlaceholder = + queuedCount > 0 + ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` + : !isUserTurn && !disabled + ? "Message will be queued..." + : placeholder; if (Platform.OS === "ios") { return ( @@ -158,10 +153,7 @@ export function Composer({ > {/* Input field with pulsing border when it's the user's turn */} - + { translateX.setValue(0); actionTriggeredRef.current = false; - }, [isArchived, translateX]); + }, [translateX]); const panResponder = useRef( PanResponder.create({ @@ -63,16 +63,13 @@ export function SwipeableTaskItem({ actionTriggeredRef.current = false; onSwipeStart?.(); }, - onPanResponderMove: Animated.event( - [null, { dx: translateX }], - { - useNativeDriver: false, - listener: (_: unknown, gesture: { dx: number }) => { - // Clamp to left-only - if (gesture.dx > 0) translateX.setValue(0); - }, + onPanResponderMove: Animated.event([null, { dx: translateX }], { + useNativeDriver: false, + listener: (_: unknown, gesture: { dx: number }) => { + // Clamp to left-only + if (gesture.dx > 0) translateX.setValue(0); }, - ), + }), onPanResponderRelease: (_, gesture) => { onSwipeEnd?.(); if (gesture.dx < -SWIPE_THRESHOLD && !actionTriggeredRef.current) { @@ -119,7 +116,7 @@ export function SwipeableTaskItem({ {/* Action revealed behind the row */} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index fbcd8c9dc..188714b6e 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -230,9 +230,7 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { size={14} color={themeColors.gray[9]} style={{ - transform: [ - { rotate: item.expanded ? "90deg" : "0deg" }, - ], + transform: [{ rotate: item.expanded ? "90deg" : "0deg" }], }} /> From 2cbc2398dad0ab890e32a487d5fcbd5000ff25ac Mon Sep 17 00:00:00 2001 From: Sandy Spicer Date: Thu, 16 Apr 2026 14:49:17 +0100 Subject: [PATCH 22/39] meep --- apps/code/src/main/services/agent/service.ts | 48 ++++++++----- apps/mobile/src/app/task/[id].tsx | 1 - .../src/features/chat/components/Composer.tsx | 9 +-- .../features/tasks/stores/taskSessionStore.ts | 71 +------------------ 4 files changed, 36 insertions(+), 93 deletions(-) diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 6ba0270ad..978cd490c 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1340,25 +1340,41 @@ For git operations while detached: return; } - if (!this.sessions.has(taskRunId)) { - const config = this.backgroundSubscriptionConfigs.get(taskRunId); - if (!config) { - log.warn("Incoming user_message with no session and no stored config", { + // Fire-and-forget the prompt dispatch. Awaiting here would block the + // LocalCommandReceiver's SSE reader on the agent's turn — which can + // park on a requestPermission, leaving the follow-up permission_response + // stranded in Redis behind this user_message. Claude SDK has + // `promptQueueing: true`, so back-to-back session/prompt calls are + // processed in order at the SDK level. + + void this.deliverUserMessage(taskRunId, content); + } + + private async deliverUserMessage( + taskRunId: string, + content: string, + ): Promise { + try { + if (!this.sessions.has(taskRunId)) { + const config = this.backgroundSubscriptionConfigs.get(taskRunId); + if (!config) { + log.warn( + "Incoming user_message with no session and no stored config", + { taskRunId }, + ); + return; + } + log.info("Lazy-spawning session to deliver mobile command", { taskRunId, }); - return; - } - log.info("Lazy-spawning session to deliver mobile command", { - taskRunId, - }); - const session = await this.getOrCreateSession(config, true); - if (!session) { - log.error("Lazy-spawn failed; dropping mobile command", { taskRunId }); - return; + const session = await this.getOrCreateSession(config, true); + if (!session) { + log.error("Lazy-spawn failed; dropping mobile command", { + taskRunId, + }); + return; + } } - } - - try { await this.prompt(taskRunId, [{ type: "text", text: content }]); } catch (err) { log.error("Failed to deliver local command to session", { diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index a1ba58115..98baef492 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -296,7 +296,6 @@ export default function TaskDetailScreen() { )} diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index a7478b69d..51ab6b979 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -18,7 +18,6 @@ interface ComposerProps { disabled?: boolean; placeholder?: string; isUserTurn?: boolean; - queuedCount?: number; } function PulsingBorder({ active, color }: { active: boolean; color: string }) { @@ -80,7 +79,6 @@ export function Composer({ disabled = false, placeholder = "Ask a question", isUserTurn = false, - queuedCount = 0, }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); @@ -115,12 +113,7 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; - const effectivePlaceholder = - queuedCount > 0 - ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` - : !isUserTurn && !disabled - ? "Message will be queued..." - : placeholder; + const effectivePlaceholder = placeholder; if (Platform.OS === "ios") { return ( diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 96609ec9d..1689e8ffe 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -85,9 +85,6 @@ export interface TaskSession { // True after a user prompt is sent, cleared when the first piece of // agent output (tool call, message, etc.) arrives from polling. awaitingAgentOutput?: boolean; - // Messages queued while the agent is working. Auto-sent when control - // returns (isPromptPending flips to false). - messageQueue?: string[]; } interface TaskSessionStore { @@ -291,24 +288,9 @@ export const useTaskSessionStore = create((set, get) => ({ throw new Error("No active session for task"); } - // If the agent is still working, queue the message for later. - if (session.isPromptPending) { - logger.debug("Agent busy, queuing message", { taskId }); - set((state) => { - const current = state.sessions[session.taskRunId]; - if (!current) return state; - return { - sessions: { - ...state.sessions, - [session.taskRunId]: { - ...current, - messageQueue: [...(current.messageQueue ?? []), prompt], - }, - }, - }; - }); - return; - } + // Mobile is a dumb relay for local runs — always push the message to + // the backend and let the desktop decide whether/when to process it. + // No local gating, no client-side queueing. // Local echo for immediate UX feedback — polling will re-surface the // canonical copy once the agent writes it to the log; any duplicate is @@ -576,7 +558,6 @@ export const useTaskSessionStore = create((set, get) => ({ terminalStatus: run.status as "failed" | "completed", lastError: run.error_message, awaitingPing: false, - messageQueue: undefined, }, }, }; @@ -861,49 +842,3 @@ export const useTaskSessionStore = create((set, get) => ({ }); }, })); - -// Watch for isPromptPending transitions (true → false) and auto-send the -// next queued message. Uses setTimeout so state is fully settled before -// sendPrompt re-enters the store. -const drainInFlight = new Set(); -useTaskSessionStore.subscribe((state, prev) => { - for (const [runId, session] of Object.entries(state.sessions)) { - const prevSession = prev.sessions[runId]; - if ( - prevSession?.isPromptPending && - !session.isPromptPending && - !session.terminalStatus && - session.messageQueue?.length && - !drainInFlight.has(runId) - ) { - drainInFlight.add(runId); - setTimeout(async () => { - try { - const current = useTaskSessionStore.getState().sessions[runId]; - if (!current?.messageQueue?.length) return; - - const [next, ...rest] = current.messageQueue; - useTaskSessionStore.setState((s) => { - const sess = s.sessions[runId]; - if (!sess) return s; - return { - sessions: { - ...s.sessions, - [runId]: { - ...sess, - messageQueue: rest.length > 0 ? rest : undefined, - }, - }, - }; - }); - - await useTaskSessionStore.getState().sendPrompt(current.taskId, next); - } catch (err) { - logger.warn("Failed to send queued message", { runId, error: err }); - } finally { - drainInFlight.delete(runId); - } - }, 50); - } - } -}); From 5b9e7312eafb053cbdcce484243fb88b28535d2b Mon Sep 17 00:00:00 2001 From: Sandy Spicer Date: Thu, 16 Apr 2026 15:34:26 +0100 Subject: [PATCH 23/39] session id --- apps/code/src/main/services/agent/service.ts | 1 + .../hooks/useBackgroundSubscriptions.ts | 40 +++++++++++++------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 978cd490c..dda756931 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1366,6 +1366,7 @@ For git operations while detached: } log.info("Lazy-spawning session to deliver mobile command", { taskRunId, + hasSessionId: !!config.sessionId, }); const session = await this.getOrCreateSession(config, true); if (!session) { diff --git a/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts b/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts index 3ecf76ebd..48541f1b3 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts @@ -1,4 +1,5 @@ import { useAuthState } from "@features/auth/hooks/authQueries"; +import { fetchSessionLogs } from "@features/sessions/utils/parseSessionLogs"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import { trpcClient } from "@renderer/trpc/client"; @@ -61,23 +62,38 @@ export function useBackgroundSubscriptions() { for (const [taskRunId, input] of desired) { if (enrolled.current.has(taskRunId)) continue; enrolled.current.add(taskRunId); - trpcClient.agent.ensureBackgroundSubscription - .mutate({ - taskId: input.taskId, - taskRunId, - repoPath: input.repoPath, - apiHost, - projectId, - logUrl: input.logUrl, - runMode: "local", - }) - .catch((err) => { + + // Same prep the renderer does when the user opens a task on desktop: + // fetch the S3 log and extract sessionId/adapter so the main process + // can resume the Claude session (with history) on lazy-spawn. + void (async () => { + try { + let sessionId: string | undefined; + let adapter: "claude" | "codex" | undefined; + if (input.logUrl) { + const parsed = await fetchSessionLogs(input.logUrl); + sessionId = parsed.sessionId; + adapter = parsed.adapter; + } + await trpcClient.agent.ensureBackgroundSubscription.mutate({ + taskId: input.taskId, + taskRunId, + repoPath: input.repoPath, + apiHost, + projectId, + logUrl: input.logUrl, + sessionId, + adapter, + runMode: "local", + }); + } catch (err) { enrolled.current.delete(taskRunId); log.warn("Failed to enroll background subscription", { taskRunId, error: err instanceof Error ? err.message : String(err), }); - }); + } + })(); } for (const taskRunId of Array.from(enrolled.current)) { From be674e7c297984580b10a792592053bf661bacf4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 05:48:50 -0700 Subject: [PATCH 24/39] fix: Generate title for attachment-only messages (#1674) ## Problem Chats started with only a pasted file and no text get stuck with "Untitled" because the title generator never fires. ## Changes 1. Use extractPromptDisplayContent to check for both text and attachments in user prompts 2. Fall back to attachment filenames when no visible text is present 3. Ensures promptCount increments so title generation triggers ## How did you test this? Manually --- apps/code/src/renderer/utils/session.test.ts | 132 ++++++++++++++++++- apps/code/src/renderer/utils/session.ts | 18 +-- 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/apps/code/src/renderer/utils/session.test.ts b/apps/code/src/renderer/utils/session.test.ts index be1a0ac82..8f62f80fa 100644 --- a/apps/code/src/renderer/utils/session.test.ts +++ b/apps/code/src/renderer/utils/session.test.ts @@ -1,6 +1,9 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { AcpMessage } from "@shared/types/session-events"; import { describe, expect, it } from "vitest"; -import { isFatalSessionError } from "./session"; +import { makeAttachmentUri } from "./promptContent"; +import { extractUserPromptsFromEvents, isFatalSessionError } from "./session"; describe("isFatalSessionError", () => { it("detects fatal 'Internal error' pattern", () => { @@ -37,3 +40,130 @@ describe("isFatalSessionError", () => { expect(isFatalSessionError("")).toBe(false); }); }); + +function promptEvent(prompt: ContentBlock[], ts = 1): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + id: ts, + method: "session/prompt", + params: { prompt }, + }, + }; +} + +describe("extractUserPromptsFromEvents", () => { + it("extracts text from a plain text prompt", () => { + const events = [promptEvent([{ type: "text", text: "fix the bug" }])]; + expect(extractUserPromptsFromEvents(events)).toEqual(["fix the bug"]); + }); + + it("skips hidden text blocks", () => { + const events = [ + promptEvent([ + { + type: "text", + text: "hidden context", + _meta: { ui: { hidden: true } }, + } as ContentBlock, + { type: "text", text: "visible prompt" }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual(["visible prompt"]); + }); + + it("returns attachment labels when prompt has no text", () => { + const uri = makeAttachmentUri("/tmp/screenshot.png"); + const events = [ + promptEvent([ + { + type: "resource", + resource: { uri, text: "", mimeType: "image/png" }, + }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "[Attached files: screenshot.png]", + ]); + }); + + it("returns text when prompt has both text and attachments", () => { + const uri = makeAttachmentUri("/tmp/data.csv"); + const events = [ + promptEvent([ + { type: "text", text: "analyze this" }, + { type: "resource", resource: { uri, text: "", mimeType: "text/csv" } }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual(["analyze this"]); + }); + + it("joins multiple attachment labels with commas", () => { + const uri1 = makeAttachmentUri("/tmp/a.png"); + const uri2 = makeAttachmentUri("/tmp/b.pdf"); + const events = [ + promptEvent([ + { + type: "resource", + resource: { uri: uri1, text: "", mimeType: "image/png" }, + }, + { + type: "resource", + resource: { uri: uri2, text: "", mimeType: "application/pdf" }, + }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "[Attached files: a.png, b.pdf]", + ]); + }); + + it("falls back to attachment labels when all text blocks are hidden", () => { + const uri = makeAttachmentUri("/tmp/report.md"); + const events = [ + promptEvent([ + { + type: "text", + text: "hidden", + _meta: { ui: { hidden: true } }, + } as ContentBlock, + { + type: "resource", + resource: { uri, text: "", mimeType: "text/markdown" }, + }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "[Attached files: report.md]", + ]); + }); + + it("skips events with empty prompt arrays", () => { + const events = [promptEvent([])]; + expect(extractUserPromptsFromEvents(events)).toEqual([]); + }); + + it("collects prompts from multiple events in order", () => { + const uri = makeAttachmentUri("/tmp/logo.svg"); + const events = [ + promptEvent([{ type: "text", text: "first" }], 1), + promptEvent( + [ + { + type: "resource", + resource: { uri, text: "", mimeType: "image/svg+xml" }, + }, + ], + 2, + ), + promptEvent([{ type: "text", text: "third" }], 3), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "first", + "[Attached files: logo.svg]", + "third", + ]); + }); +}); diff --git a/apps/code/src/renderer/utils/session.ts b/apps/code/src/renderer/utils/session.ts index d1400dc75..6809b35a1 100644 --- a/apps/code/src/renderer/utils/session.ts +++ b/apps/code/src/renderer/utils/session.ts @@ -182,14 +182,16 @@ export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] { if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { const params = msg.params as { prompt?: ContentBlock[] }; if (params?.prompt?.length) { - // Find first visible text block (skip hidden context blocks) - const textBlock = params.prompt.find((b) => { - if (b.type !== "text") return false; - const meta = (b as { _meta?: { ui?: { hidden?: boolean } } })._meta; - return !meta?.ui?.hidden; - }); - if (textBlock && textBlock.type === "text") { - prompts.push(textBlock.text); + const { text, attachments } = extractPromptDisplayContent( + params.prompt, + { filterHidden: true }, + ); + + if (text) { + prompts.push(text); + } else if (attachments.length > 0) { + const labels = attachments.map((a) => a.label).join(", "); + prompts.push(`[Attached files: ${labels}]`); } } } From 56ed7b291ca17d48fefd776ff5035622083077d8 Mon Sep 17 00:00:00 2001 From: Jonathan Mieloo <32547391+jonathanlab@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:54:49 +0200 Subject: [PATCH 25/39] feat: auto recover on disconnect (#1687) --- .../features/sessions/service/service.test.ts | 143 +++++++++++- .../features/sessions/service/service.ts | 207 ++++++++++++++++-- 2 files changed, 319 insertions(+), 31 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index e7d235e8a..46c210c30 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -299,6 +299,12 @@ describe("SessionService", () => { hasCodeAccess: true, needsScopeReauth: false, }); + mockTrpcAgent.onSessionEvent.subscribe.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockTrpcAgent.onPermissionRequest.subscribe.mockReturnValue({ + unsubscribe: vi.fn(), + }); mockTrpcCloudTask.onUpdate.subscribe.mockReturnValue({ unsubscribe: vi.fn(), }); @@ -1027,29 +1033,56 @@ describe("SessionService", () => { ); }); - it("sets session to error state on fatal error", async () => { + it("attempts automatic recovery on fatal error", async () => { const service = getSessionService(); - const mockSession = createMockSession(); + const mockSession = createMockSession({ + logUrl: "https://logs.example.com/run-123", + }); mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession); mockSessionStoreSetters.getSessions.mockReturnValue({ "run-123": { ...mockSession, isPromptPending: false }, }); + mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true }); + mockTrpcLogs.readLocalLogs.query.mockResolvedValue(""); + mockTrpcAgent.reconnect.mutate.mockResolvedValue({ + sessionId: "run-123", + channel: "agent-event:run-123", + configOptions: [], + }); + + await service.connectToTask({ + task: createMockTask({ + latest_run: { + id: "run-123", + task: "task-123", + team: 123, + environment: "local", + status: "in_progress", + log_url: "https://logs.example.com/run-123", + error_message: null, + output: null, + state: {}, + branch: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + completed_at: null, + }, + }), + repoPath: "/repo", + }); + mockTrpcAgent.prompt.mutate.mockRejectedValue( new Error("Internal error: process exited"), ); await expect(service.sendPrompt("task-123", "Hello")).rejects.toThrow(); - - // Check that one of the updateSession calls set status to error - const updateCalls = mockSessionStoreSetters.updateSession.mock.calls as [ - string, - { status?: string }, - ][]; - const errorCall = updateCalls.find( - ([, updates]) => updates.status === "error", + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "run-123", + expect.objectContaining({ + status: "disconnected", + errorMessage: expect.stringContaining("Reconnecting"), + }), ); - expect(errorCall).toBeDefined(); - expect(errorCall?.[0]).toBe("run-123"); }); }); @@ -1366,4 +1399,90 @@ describe("SessionService", () => { ).resolves.not.toThrow(); }); }); + + describe("automatic local recovery", () => { + it("reconnects automatically after a subscription error", async () => { + vi.useFakeTimers(); + const service = getSessionService(); + const mockSession = createMockSession({ + status: "connected", + logUrl: "https://logs.example.com/run-123", + }); + + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-123": mockSession, + }); + mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true }); + mockTrpcLogs.readLocalLogs.query.mockResolvedValue(""); + mockTrpcAgent.reconnect.mutate.mockResolvedValue({ + sessionId: "run-123", + channel: "agent-event:run-123", + configOptions: [], + }); + + await service.clearSessionError("task-123", "/repo"); + + const onError = mockTrpcAgent.onSessionEvent.subscribe.mock.calls[0]?.[1] + ?.onError as ((error: Error) => void) | undefined; + expect(onError).toBeDefined(); + + onError?.(new Error("connection dropped")); + await vi.runAllTimersAsync(); + + expect(mockTrpcAgent.reconnect.mutate).toHaveBeenCalledTimes(2); + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "run-123", + expect.objectContaining({ + status: "disconnected", + errorMessage: expect.stringContaining("Reconnecting"), + }), + ); + + vi.useRealTimers(); + }); + + it("shows the error screen only after automatic reconnect attempts fail", async () => { + vi.useFakeTimers(); + const service = getSessionService(); + const mockSession = createMockSession({ + status: "connected", + logUrl: "https://logs.example.com/run-123", + }); + + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-123": mockSession, + }); + mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true }); + mockTrpcLogs.readLocalLogs.query.mockResolvedValue(""); + mockTrpcAgent.reconnect.mutate + .mockResolvedValueOnce({ + sessionId: "run-123", + channel: "agent-event:run-123", + configOptions: [], + }) + .mockResolvedValue(null); + + await service.clearSessionError("task-123", "/repo"); + + const onError = mockTrpcAgent.onSessionEvent.subscribe.mock.calls[0]?.[1] + ?.onError as ((error: Error) => void) | undefined; + expect(onError).toBeDefined(); + + onError?.(new Error("connection dropped")); + await vi.runAllTimersAsync(); + + expect(mockTrpcAgent.reconnect.mutate).toHaveBeenCalledTimes(4); + expect(mockSessionStoreSetters.setSession).toHaveBeenCalledWith( + expect.objectContaining({ + status: "error", + errorTitle: "Connection lost", + errorMessage: expect.any(String), + }), + ); + + vi.useRealTimers(); + }); + }); }); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index dc5b8e5a6..549426cbe 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -54,6 +54,7 @@ import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; import type { AcpMessage, StoredLogEntry } from "@shared/types/session-events"; import { isJsonRpcRequest } from "@shared/types/session-events"; +import { getBackoffDelay } from "@shared/utils/backoff"; import { buildPermissionToolMetadata, track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { @@ -73,6 +74,15 @@ import { } from "@utils/session"; const log = logger.scope("session-service"); +const LOCAL_SESSION_RECONNECT_ATTEMPTS = 3; +const LOCAL_SESSION_RECONNECT_BACKOFF = { + initialDelayMs: 1_000, + maxDelayMs: 5_000, +}; +const LOCAL_SESSION_RECOVERY_MESSAGE = + "Lost connection to the agent. Reconnecting…"; +const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE = + "Connecting to to the agent has been lost. Retry, or start a new session."; /** * Build default configOptions for cloud sessions so the mode switcher @@ -140,6 +150,8 @@ export function resetSessionService(): void { export class SessionService { private connectingTasks = new Map>(); + private localRepoPaths = new Map(); + private localRecoveryAttempts = new Map>(); private nextCloudTaskWatchToken = 0; private subscriptions = new Map< string, @@ -186,6 +198,7 @@ export class SessionService { async connectToTask(params: ConnectParams): Promise { const { task } = params; const taskId = task.id; + this.localRepoPaths.set(taskId, params.repoPath); log.info("Connecting to task", { taskId }); @@ -378,7 +391,7 @@ export class SessionService { sessionId?: string; adapter?: Adapter; }, - ): Promise { + ): Promise { const { rawEntries, sessionId, adapter } = prefetchedLogs ?? (await this.fetchSessionLogs(logUrl, taskRunId)); const events = convertStoredEntriesToEvents(rawEntries); @@ -495,6 +508,7 @@ export class SessionService { ), ); } + return true; } else { log.warn("Reconnect returned null", { taskId, taskRunId }); this.setErrorSession( @@ -503,6 +517,7 @@ export class SessionService { taskTitle, "Session could not be resumed. Please retry or start a new session.", ); + return false; } } catch (error) { const errorMessage = @@ -515,10 +530,13 @@ export class SessionService { errorMessage || "Failed to reconnect. Please retry or start a new session.", ); + return false; } } private async teardownSession(taskRunId: string): Promise { + const session = this.getSessionByRunId(taskRunId); + try { await trpcClient.agent.cancel.mutate({ sessionId: taskRunId }); } catch (error) { @@ -530,6 +548,10 @@ export class SessionService { this.unsubscribeFromChannel(taskRunId); sessionStoreSetters.removeSession(taskRunId); + if (session) { + this.localRepoPaths.delete(session.taskId); + this.localRecoveryAttempts.delete(session.taskId); + } useSessionAdapterStore.getState().removeAdapter(taskRunId); removePersistedConfigOptions(taskRunId); } @@ -581,6 +603,133 @@ export class SessionService { sessionStoreSetters.setSession(session); } + private async tryAutoRecoverLocalSession( + taskId: string, + taskRunId: string, + reason: string, + ): Promise { + const existingRecovery = this.localRecoveryAttempts.get(taskId); + if (existingRecovery) { + return existingRecovery; + } + + const recoveryPromise = this.runAutoRecoverLocalSession( + taskId, + taskRunId, + reason, + ).finally(() => { + this.localRecoveryAttempts.delete(taskId); + }); + + this.localRecoveryAttempts.set(taskId, recoveryPromise); + return recoveryPromise; + } + + private async runAutoRecoverLocalSession( + taskId: string, + taskRunId: string, + reason: string, + ): Promise { + const repoPath = this.localRepoPaths.get(taskId); + const session = sessionStoreSetters.getSessionByTaskId(taskId); + if (!repoPath || !session || session.isCloud) { + return false; + } + + log.warn("Attempting automatic local session recovery", { + taskId, + taskRunId, + reason, + }); + + sessionStoreSetters.updateSession(taskRunId, { + status: "disconnected", + errorTitle: undefined, + errorMessage: LOCAL_SESSION_RECOVERY_MESSAGE, + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + }); + + for ( + let attempt = 0; + attempt < LOCAL_SESSION_RECONNECT_ATTEMPTS; + attempt++ + ) { + const currentSession = sessionStoreSetters.getSessionByTaskId(taskId); + if (!currentSession || currentSession.taskRunId !== taskRunId) { + return false; + } + + if (attempt > 0) { + const delay = getBackoffDelay( + attempt - 1, + LOCAL_SESSION_RECONNECT_BACKOFF, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + const recovered = await this.reconnectInPlace(taskId, repoPath); + if (recovered) { + log.info("Automatic local session recovery succeeded", { + taskId, + taskRunId, + attempt: attempt + 1, + }); + return true; + } + } + + const latestSession = sessionStoreSetters.getSessionByTaskId(taskId); + if (latestSession?.taskRunId === taskRunId) { + this.setErrorSession( + taskId, + taskRunId, + latestSession.taskTitle, + LOCAL_SESSION_RECOVERY_FAILED_MESSAGE, + "Connection lost", + ); + } + + log.warn("Automatic local session recovery exhausted", { + taskId, + taskRunId, + }); + + return false; + } + + private startAutoRecoverLocalSession( + taskId: string, + taskRunId: string, + taskTitle: string, + reason: string, + fallbackMessage: string, + ): void { + void this.tryAutoRecoverLocalSession(taskId, taskRunId, reason).then( + (recovered) => { + if (recovered) { + return; + } + + const latestSession = sessionStoreSetters.getSessionByTaskId(taskId); + if (!latestSession || latestSession.taskRunId !== taskRunId) { + return; + } + + if (latestSession.status !== "error") { + this.setErrorSession( + taskId, + taskRunId, + taskTitle, + fallbackMessage, + "Connection lost", + ); + } + }, + ); + } + private async createNewLocalSession( taskId: string, taskTitle: string, @@ -703,11 +852,23 @@ export class SessionService { }, onError: (err) => { log.error("Session subscription error", { taskRunId, error: err }); - sessionStoreSetters.updateSession(taskRunId, { - status: "error", - errorMessage: - "Lost connection to the agent. Please restart the task.", - }); + const session = this.getSessionByRunId(taskRunId); + if (!session || session.isCloud) { + sessionStoreSetters.updateSession(taskRunId, { + status: "error", + errorMessage: + "Lost connection to the agent. Please restart the task.", + }); + return; + } + + this.startAutoRecoverLocalSession( + session.taskId, + taskRunId, + session.taskTitle, + "subscription_error", + "Lost connection to the agent. Please retry or start a new session.", + ); }, }, ); @@ -787,6 +948,8 @@ export class SessionService { } this.connectingTasks.clear(); + this.localRepoPaths.clear(); + this.localRecoveryAttempts.clear(); this.cloudPermissionRequestIds.clear(); this.idleKilledSubscription?.unsubscribe(); this.idleKilledSubscription = null; @@ -1202,21 +1365,19 @@ export class SessionService { sessionStoreSetters.clearOptimisticItems(session.taskRunId); if (isFatalSessionError(errorMessage, errorDetails)) { - log.error("Fatal prompt error, setting session to error state", { + log.error("Fatal prompt error, attempting recovery", { taskRunId: session.taskRunId, errorMessage, errorDetails, }); - sessionStoreSetters.updateSession(session.taskRunId, { - status: "error", - errorMessage: - errorDetails || + this.startAutoRecoverLocalSession( + session.taskId, + session.taskRunId, + session.taskTitle, + errorDetails || errorMessage, + errorDetails || "Session connection lost. Please retry or start a new session.", - isPromptPending: false, - isCompacting: false, - promptStartedAt: null, - initialPrompt: undefined, - }); + ); } else { sessionStoreSetters.updateSession(session.taskRunId, { isPromptPending: false, @@ -1974,6 +2135,7 @@ export class SessionService { * to an empty session. */ async clearSessionError(taskId: string, repoPath: string): Promise { + this.localRepoPaths.set(taskId, repoPath); const session = sessionStoreSetters.getSessionByTaskId(taskId); if (session?.initialPrompt?.length) { const { taskTitle, initialPrompt } = session; @@ -2002,6 +2164,7 @@ export class SessionService { * session instead of attempting to resume the stale one. */ async resetSession(taskId: string, repoPath: string): Promise { + this.localRepoPaths.set(taskId, repoPath); await this.reconnectInPlace(taskId, repoPath, null); } @@ -2019,9 +2182,10 @@ export class SessionService { taskId: string, repoPath: string, overrideSessionId?: string | null, - ): Promise { + ): Promise { + this.localRepoPaths.set(taskId, repoPath); const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return; + if (!session) return false; const { taskRunId, taskTitle, logUrl } = session; @@ -2047,7 +2211,7 @@ export class SessionService { ? undefined : (overrideSessionId ?? prefetchedLogs.sessionId); - await this.reconnectToLocalSession( + return this.reconnectToLocalSession( taskId, taskRunId, taskTitle, @@ -2634,6 +2798,11 @@ export class SessionService { }; } + private getSessionByRunId(taskRunId: string): AgentSession | undefined { + const sessions = sessionStoreSetters.getSessions(); + return sessions[taskRunId]; + } + private async appendAndPersist( taskId: string, session: AgentSession, From 46df7e316c8b2c0c6cd1c0a3d7993db5e00c0976 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 06:01:01 -0700 Subject: [PATCH 26/39] feat: Add task search and sidebar context menu integration to command center (#1675) ## Problem Command center has no search when adding tasks and no way to add tasks from the sidebar. Context menu addition: ![CleanShot 2026-04-15 at 14.54.58@2x.png](https://app.graphite.com/user-attachments/assets/bbf88cca-c5b2-48df-8323-31d8ad98dd25.png) Search: ## ![CleanShot 2026-04-15 at 14.30.59@2x.png](https://app.graphite.com/user-attachments/assets/af43bf52-7fc3-47b8-ba1a-11b7657c2b23.png) ## Changes 1. Replace plain task list in TaskSelector with searchable Combobox (fuzzy filter by title) 2. Add "Add to Command Center" to sidebar task right-click context menu 3. Hide context menu item when task is already in command center, disable when no empty cells 4. Navigate to command center after adding a task via context menu ## How did you test this? Manually --- .../src/main/services/context-menu/schemas.ts | 3 + .../src/main/services/context-menu/service.ts | 19 ++++- .../components/TaskSelector.tsx | 71 +++++++++++-------- .../sidebar/components/SidebarMenu.tsx | 20 ++++++ .../src/renderer/hooks/useTaskContextMenu.ts | 11 +++ 5 files changed, 94 insertions(+), 30 deletions(-) diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts index d11d80eb4..65120b4a3 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/apps/code/src/main/services/context-menu/schemas.ts @@ -6,6 +6,8 @@ export const taskContextMenuInput = z.object({ folderPath: z.string().optional(), isPinned: z.boolean().optional(), isSuspended: z.boolean().optional(), + isInCommandCenter: z.boolean().optional(), + hasEmptyCommandCenterCell: z.boolean().optional(), }); export const archivedTaskContextMenuInput = z.object({ @@ -40,6 +42,7 @@ const taskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("archive") }), z.object({ type: z.literal("archive-prior") }), z.object({ type: z.literal("delete") }), + z.object({ type: z.literal("add-to-command-center") }), z.object({ type: z.literal("external-app"), action: externalAppAction }), ]); diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index adb49f1da..1308e2a22 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -104,7 +104,14 @@ export class ContextMenuService { async showTaskContextMenu( input: TaskContextMenuInput, ): Promise { - const { worktreePath, folderPath, isPinned, isSuspended } = input; + const { + worktreePath, + folderPath, + isPinned, + isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, + } = input; const { apps, lastUsedAppId } = await this.getExternalAppsData(); const hasPath = worktreePath || folderPath; @@ -126,6 +133,16 @@ export class ContextMenuService { ...this.externalAppItems(apps, lastUsedAppId), ] : []), + ...(!isInCommandCenter + ? [ + this.separator(), + this.item( + "Add to Command Center", + { type: "add-to-command-center" as const }, + { enabled: hasEmptyCommandCenterCell ?? true }, + ), + ] + : []), this.separator(), this.item("Archive", { type: "archive" }), this.item( diff --git a/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx b/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx index c12113f3a..83b82d422 100644 --- a/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx +++ b/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx @@ -1,5 +1,6 @@ +import { Combobox } from "@components/ui/combobox/Combobox"; import { Plus } from "@phosphor-icons/react"; -import { Popover, Separator } from "@radix-ui/themes"; +import { Popover } from "@radix-ui/themes"; import { useNavigationStore } from "@stores/navigationStore"; import { type ReactNode, useCallback } from "react"; import { useAvailableTasks } from "../hooks/useAvailableTasks"; @@ -42,42 +43,54 @@ export function TaskSelector({ }, [onOpenChange, onNewTask, navigateToTaskInput]); return ( - + {children} - task.title} side="bottom" align="center" sideOffset={4} - style={{ padding: 4, minWidth: 240, maxHeight: 300 }} + style={{ minWidth: 240 }} > - - -
- {availableTasks.length === 0 ? ( -
- No available tasks -
- ) : ( - availableTasks.map((task) => ( - - )) - )} -
-
-
+ + + )} + + ); } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index f56771b46..819425ddd 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -77,6 +77,7 @@ function SidebarMenuComponent() { } const commandCenterCells = useCommandCenterStore((s) => s.cells); + const assignTaskToCommandCenter = useCommandCenterStore((s) => s.assignTask); const commandCenterActiveCount = commandCenterCells.filter( (taskId) => taskId != null && taskMap.has(taskId), ).length; @@ -138,13 +139,32 @@ function SidebarMenuComponent() { if (task) { const workspace = workspaces[taskId]; const taskData = allSidebarTasks.find((t) => t.id === taskId); + const isInCommandCenter = commandCenterCells.some( + (id) => id === taskId && taskMap.has(id), + ); + const hasEmptyCommandCenterCell = commandCenterCells.some( + (id) => id == null || !taskMap.has(id), + ); + showContextMenu(task, e, { worktreePath: workspace?.worktreePath ?? undefined, folderPath: workspace?.folderPath ?? undefined, isPinned, isSuspended: taskData?.isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, onTogglePin: () => togglePin(taskId), onArchivePrior: handleArchivePrior, + onAddToCommandCenter: () => { + const cells = useCommandCenterStore.getState().cells; + const idx = cells.findIndex((id) => id == null || !taskMap.has(id)); + if (idx !== -1) { + assignTaskToCommandCenter(idx, taskId); + navigateToCommandCenter(); + } else { + toast.info("Command center is full"); + } + }, }); } }; diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/apps/code/src/renderer/hooks/useTaskContextMenu.ts index 63dac5268..b3ccff57f 100644 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ b/apps/code/src/renderer/hooks/useTaskContextMenu.ts @@ -28,8 +28,11 @@ export function useTaskContextMenu() { folderPath?: string; isPinned?: boolean; isSuspended?: boolean; + isInCommandCenter?: boolean; + hasEmptyCommandCenterCell?: boolean; onTogglePin?: () => void; onArchivePrior?: (taskId: string) => void; + onAddToCommandCenter?: () => void; }, ) => { event.preventDefault(); @@ -40,8 +43,11 @@ export function useTaskContextMenu() { folderPath, isPinned, isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, onTogglePin, onArchivePrior, + onAddToCommandCenter, } = options ?? {}; try { @@ -51,6 +57,8 @@ export function useTaskContextMenu() { folderPath, isPinned, isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, }); if (!result.action) return; @@ -86,6 +94,9 @@ export function useTaskContextMenu() { hasWorktree: !!worktreePath, }); break; + case "add-to-command-center": + onAddToCommandCenter?.(); + break; case "external-app": { const effectivePath = worktreePath ?? folderPath; if (effectivePath) { From c866b6cbf992045831475bbc0b67ad519d9ca12c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 06:09:10 -0700 Subject: [PATCH 27/39] fix: Prevent auto-generated title from overwriting manual rename (#1676) ## Problem Auto-title generation silently bypassed the title_manually_set guard because getQueryData used exact key matching while task queries include filter objects in their keys. Closes https://github.com/PostHog/code/issues/1669 ## Changes 1. Fix cache lookup in useChatTitleGenerator to use getQueriesData (prefix match) instead of getQueryData (exact match) 2. Add title_manually_set guard to generateTaskTitle at task creation time ## How did you test this? Manually --- .../sessions/hooks/useChatTitleGenerator.ts | 13 ++-- .../renderer/sagas/task/task-creation.test.ts | 78 ++++++++++++++++++- .../src/renderer/sagas/task/task-creation.ts | 7 +- .../src/renderer/utils/queryClient.test.ts | 63 +++++++++++++++ apps/code/src/renderer/utils/queryClient.ts | 8 ++ 5 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 apps/code/src/renderer/utils/queryClient.test.ts diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index d66695126..ee3749426 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -7,7 +7,7 @@ import { import type { Task } from "@shared/types"; import { generateTitleAndSummary } from "@utils/generateTitle"; import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; +import { getCachedTask, queryClient } from "@utils/queryClient"; import { extractUserPromptsFromEvents } from "@utils/session"; import { useEffect, useRef } from "react"; @@ -65,17 +65,14 @@ export function useChatTitleGenerator(taskId: string): void { const run = async () => { try { - const cachedTasks = queryClient.getQueryData(["tasks", "list"]); - const cachedTask = cachedTasks?.find((t) => t.id === taskId); - if (cachedTask?.title_manually_set) { - log.debug("Skipping auto-title, user renamed task", { taskId }); - return; - } - const result = await generateTitleAndSummary(content); if (result) { const { title, summary } = result; if (title) { + if (getCachedTask(taskId)?.title_manually_set) { + log.debug("Skipping auto-title, user renamed task", { taskId }); + return; + } const client = await getAuthenticatedClient(); if (client) { await client.updateTask(taskId, { title }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index ee2e36aff..9b808de7d 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -6,6 +6,7 @@ const mockWorkspaceDelete = vi.hoisted(() => vi.fn()); const mockGetTaskDirectory = vi.hoisted(() => vi.fn()); const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); const mockReadFileAsBase64 = vi.hoisted(() => vi.fn()); +const mockGetCachedTask = vi.hoisted(() => vi.fn()); vi.mock("@renderer/trpc", () => ({ trpcClient: { @@ -52,14 +53,16 @@ vi.mock("@features/sessions/service/service", () => ({ }), })); +const mockGenerateTitleAndSummary = vi.hoisted(() => vi.fn()); vi.mock("@renderer/utils/generateTitle", () => ({ - generateTitleAndSummary: vi.fn(async () => null), + generateTitleAndSummary: mockGenerateTitleAndSummary, })); vi.mock("@utils/queryClient", () => ({ queryClient: { setQueriesData: vi.fn(), }, + getCachedTask: mockGetCachedTask, })); vi.mock("@utils/logger", () => ({ @@ -179,6 +182,79 @@ describe("TaskCreationSaga", () => { ); }); + it("skips auto-title when task has been manually renamed", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const updateTaskMock = vi.fn(); + + mockGenerateTitleAndSummary.mockResolvedValue({ title: "Auto title" }); + mockGetCachedTask.mockReturnValue({ + id: "task-123", + title_manually_set: true, + }); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + runTaskInCloud: runTaskInCloudMock, + sendRunCommand: vi.fn(), + updateTask: updateTaskMock, + } as never, + }); + + await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + await vi.waitFor(() => { + expect(mockGenerateTitleAndSummary).toHaveBeenCalled(); + }); + + expect(updateTaskMock).not.toHaveBeenCalled(); + }); + + it("applies auto-title when task has not been manually renamed", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const updateTaskMock = vi.fn().mockResolvedValue(undefined); + + mockGenerateTitleAndSummary.mockResolvedValue({ title: "Auto title" }); + mockGetCachedTask.mockReturnValue(undefined); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + runTaskInCloud: runTaskInCloudMock, + sendRunCommand: vi.fn(), + updateTask: updateTaskMock, + } as never, + }); + + await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + await vi.waitFor(() => { + expect(updateTaskMock).toHaveBeenCalledWith("task-123", { + title: "Auto title", + }); + }); + }); + it("sends initial cloud prompts with attachments as pending user messages", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 68d351271..27d179467 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -24,7 +24,7 @@ import type { ExecutionMode, Task } from "@shared/types"; import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; import { getGhUserTokenOrThrow } from "@utils/github"; import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; +import { getCachedTask, queryClient } from "@utils/queryClient"; const log = logger.scope("task-creation-saga"); @@ -39,6 +39,11 @@ async function generateTaskTitle( if (!result?.title) return; const { title } = result; + if (getCachedTask(taskId)?.title_manually_set) { + log.debug("Skipping auto-title, user renamed task", { taskId }); + return; + } + try { await posthogClient.updateTask(taskId, { title }); diff --git a/apps/code/src/renderer/utils/queryClient.test.ts b/apps/code/src/renderer/utils/queryClient.test.ts new file mode 100644 index 000000000..2551570dd --- /dev/null +++ b/apps/code/src/renderer/utils/queryClient.test.ts @@ -0,0 +1,63 @@ +import type { Task } from "@shared/types"; +import { beforeEach, describe, expect, it } from "vitest"; +import { getCachedTask, queryClient } from "./queryClient"; + +const createTask = (overrides: Partial = {}): Task => ({ + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Test task", + description: "", + origin_product: "user_created", + repository: null, + created_at: "2026-04-15T00:00:00Z", + updated_at: "2026-04-15T00:00:00Z", + ...overrides, +}); + +describe("getCachedTask", () => { + beforeEach(() => { + queryClient.clear(); + }); + + it("returns matching task from cache", () => { + const tasks = [createTask({ id: "task-1" }), createTask({ id: "task-2" })]; + queryClient.setQueryData(["tasks", "list"], tasks); + + expect(getCachedTask("task-1")?.id).toBe("task-1"); + expect(getCachedTask("task-2")?.id).toBe("task-2"); + }); + + it("returns undefined when task is not in cache", () => { + queryClient.setQueryData(["tasks", "list"], [createTask({ id: "task-1" })]); + + expect(getCachedTask("nonexistent")).toBeUndefined(); + }); + + it("returns undefined when no task queries exist", () => { + expect(getCachedTask("task-1")).toBeUndefined(); + }); + + it("searches across multiple task list queries", () => { + queryClient.setQueryData( + ["tasks", "list", { folder: "a" }], + [createTask({ id: "task-a" })], + ); + queryClient.setQueryData( + ["tasks", "list", { folder: "b" }], + [createTask({ id: "task-b" })], + ); + + expect(getCachedTask("task-a")?.id).toBe("task-a"); + expect(getCachedTask("task-b")?.id).toBe("task-b"); + }); + + it("preserves title_manually_set flag", () => { + queryClient.setQueryData( + ["tasks", "list"], + [createTask({ id: "task-1", title_manually_set: true })], + ); + + expect(getCachedTask("task-1")?.title_manually_set).toBe(true); + }); +}); diff --git a/apps/code/src/renderer/utils/queryClient.ts b/apps/code/src/renderer/utils/queryClient.ts index 96916e9f8..743432015 100644 --- a/apps/code/src/renderer/utils/queryClient.ts +++ b/apps/code/src/renderer/utils/queryClient.ts @@ -1,3 +1,4 @@ +import type { Task } from "@shared/types"; import { QueryClient } from "@tanstack/react-query"; export const queryClient = new QueryClient({ @@ -8,3 +9,10 @@ export const queryClient = new QueryClient({ }, }, }); + +export function getCachedTask(taskId: string): Task | undefined { + return queryClient + .getQueriesData({ queryKey: ["tasks", "list"] }) + .flatMap(([, tasks]) => tasks ?? []) + .find((t) => t.id === taskId); +} From 7e7ff37614307f94302914988930dbed35b7d002 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 06:21:46 -0700 Subject: [PATCH 28/39] feat: Implement a background to the installer dmg (#1662) ## Problem The macOS DMG installer opens with a plain white background and no visual guidance for drag-to-install. ## ![CleanShot 2026-04-14 at 21.23.26@2x.png](https://app.graphite.com/user-attachments/assets/e5cb2830-816c-410d-9885-02a6cbd48426.png) Closes https://github.com/PostHog/code/issues/638 ## Changes 1. Add custom background image for the DMG window 2. Configure icon size, positions and window dimensions to match the background layout 3. Place app and Applications shortcut at correct coordinates for the drag-to-install flow ## How did you test this? Manually --- apps/code/build/dmg-background.png | Bin 0 -> 9720 bytes apps/code/forge.config.ts | 7 +++++++ 2 files changed, 7 insertions(+) create mode 100644 apps/code/build/dmg-background.png diff --git a/apps/code/build/dmg-background.png b/apps/code/build/dmg-background.png new file mode 100644 index 0000000000000000000000000000000000000000..895453103121b6deac6e228ba69e714903658ff8 GIT binary patch literal 9720 zcmeHtc|4Tu_wdX#o(9R_$-WGV9%M;mH(4Utk`S`iDC=M{w!xG{7_=&~gk&fCR?}na z2_ehKGKuUaWX<}!EIq&P`~B;EKcDx{d_Lp8uj@Y7*{*ZWea^Y=2xCJXCI)T>2n51( zPFKqW0)g>DAkaX>A7CbQ*r6I+;7(@@&OjhVF^n7bbYP5f)HN}HK>S4@kdROaWDQJ( zOh6zvr6G_hI|xK69s=ROCRSZg0T1q7vp9z_Fo1}Gdjy0I$_?4U00loiD9^w5CeY)M z1H1Du@K6Q<{zG;cu)KQ%0^gm7KcGmA+&)#ryvxh(qq(JxAgC{AlUu$5z zDF=StLj!jfV|yU%;6jhpz3dHvFtY9Zppa*YoB-$L8>SXG3j=*6M-Mki`>P%f*Cc)2 zusf?DD!xkK*6kY39_8!i>h7)Nt17e$p#<)Crlo{XyC66hRUr!lW0atuF&!-#qUqbHQ`%(e< zq~S7mrXC)yYP;H1k^1lHeL5;qJH-EknLSB&XMw0{ z3}EFiwW%?{x2@kpAj00~w9c6NLTQ6WudFGAgLCT=Wix^4ObuB8Pt9dZT(66K+g;1<{Y%kkt*F+RH38C;{GDCx|SoWViUDf>)l~@%2-ca zc(A4zY^}4rklc5lI=vZvv%6!WC3$5m(GNcscsR;w>|^bC;<_Rwsi3VEPqmayo%#Mf zC}nfCm^MOO_IyF8U-vx@M<7w?5C~DA3_kDcY)^wCFnMDCYJTEm6-#aCd>NtLw zIP)D+mX;X`J@gV7p)sm36vm>&zuA{_n^AA2fAh@5YTx8Lx{UQa;W9R*v5w*sM1MG( zSfrSz3QHa( zeC=msZ|8-=nuSnUiDunNQKAg2mVwXe3%gQd)Pn}NGY3+^SsgaBt%pGHiOWR!+#>4% z=Mh(}@yQnO&6A#zDw!^!?YiL`B|o#HUu|YtqJmCI zd`SM7iXB-uVlI2L{%w5h)#&qfmLt4tbAd}O-Sj+csCFKxEN?9r1E1g=iCoIFuvrSw zOKo`l@bJ?wCbfIgJhol?oG+@!JSCZLjd;AwccO#A&1}&_7Mn6Zz{pFBTt-6lBW zhE0H*`zV5Uye($Ca;cKX7z%~e0@3}|wUjK<&>!39BWXVk3ubIKXE7_?PsaGeM3a19 z_^nOi^O>JkpF9Fg9tU>y6Vubf6rosg+{SSCa}2e;ql74|GvPZ^)D%jxJZ!gq&Q1$G z#0i~$j82A!lGLKZ-T-rq2_ehGB)QQ`pivr4J?E8MKL(yII7LX9tiVlvf-Rmp6N0t{ zyZa_8Gzb}wntSWfbSM3^nHVln>1H;IlE|T|7({M-2)W4NfHU6A+^$=rX?fX47?=Bf z-nk7C1uQg8h{`rmgbpPoxBX1BDGDGc)(YVkSM56GU{C4bq?VLyVPngoM-+TQ@*9;e zdHEepYIjIF0)&#!eSKmEnL#l4VPo`=0y@xut+!xD7e79(FZV~|%Ob5Xw(FzTH??}h zJH<7ctfS;b+FhW;G)i5XH@uNiYr;22wc(`4&&f#hgdCbtYTKp!tz51Ru}R)X2tMzj z&kxD02n6qWY27MayF~J%W}t#6~#tyVOn?AdDT zes`bXwOIpiS{}iih3D)17G`ni_>Y$KY@$N zn1>QGkVu8c4)M7A9>L%zQiH}g@pQ|9G5v)Zbi_uAqlhCrxQ4BA*lVYMJ! z=0dnc)c~J?rpLhV908@K+i5OvFNC#AaP%o)usOAya`*`azaQWDsulji)_*WkT{86^ zYk8_%Rtz$eAP@mI`2}i)W3^Ghf1DJ&?vg0O%b(-!Tgtz_*Dk{_tV%q#!<1btE)IQ| zI8E^xPf88vA|oWWkNuhSB%vNGVE6>~xuo#F&@sdk&ZoQ%7R z?uM-dx^Go0ZGG*V6=kCIJR7?*Yw0rVXX!UvaH{ogu+(Pq-QF!#{At;Z^iXkGObp-bN6eVhe-mylvFuKxYm_ zYY{q9e$r))rV?^ozb^s@ia7}ule}L;;1OL07%$0nGq2l@?YN^z~?iM;U`eLRb zP2Ba6IghNcC&T{p_^%4O1UI&L6jD$yznjy1xZnl9vX3ivP&4#FMVaHO(5J7o;fE~| zY@!>-y6yigphsGO09It|9?cSOFqD;<#BGgwW*OkZUwK zH*7W}g3-uvb8|qIbqObXYS67K%8R*QQ& zI-kc)E1|MAY<)rw3Jc(nt}DXJS|Kg`iK{03Cp@d&@YAmFP~Up(B+s>@OQVB>=VT?( z^*%B@EK6DuNVZhLzsc#W_Lveab(3Hoktw5AT>7#{iQz0TtlSVTQiH!sG})B6dMj!4 z#fPjT(U%4DS-74Tgt6s|~9zL7OYMJOzi1*>;W=A)&cbTN9W zSrw);6-gM=yKAOT;L<_Fn+`9Sq9W8X)*e~nE4I!hXHM}@eI8R3r{XjCra>-M4RWdK zM{@KMT0>jwvqojbPqv$5j9D_#!z{rMGODkg5+il8t^cL?w%)s^G?`Gu1PF2fg7agR zN2M>IBuV<4Hz^3-`OoQ}iTT5;0b+v#&2Z03X#(_G+F}F zF*?KC_s(tVZ}#X?p7y%q*hTW!IXUX9Xkp103EW!)|7HG9t_}Eg(1p2=uKpdv6rYx!Nuaxt%bO-5Km!hct+|4hTJ=a8o8gQoy^|Qs-WU4$a*qXn1 z)6r%dIN$JX@c5womG!L73xi5~PLOGOxW{%Hejny(uA5!|G4Q zFY+tA(-Y!!2lcp%#RfT6u>BooDre7|>xhxHY)v*N*3>O2bL(AF*^N?(FAe{?DL9jQ zm*07Ko_v7$Xg^>67a+*tvvzLfCk}R&n9(ESk0Nagvo4C)_2ZyIZ+u5&^6Ope=UQ|* z3{9q+Wg;8;Rgej70N#E^?(k;Of%L{-N6 zhWi0O#f#FIaI!pU_QE|pOiXG0qyPZ|_hFtQo0^!>N+ zdahPH?;U?73!`A47rpIxTTzWESXZ`wOMxREf_mozj|*|{4K6jrO`2kcLUvmrDK#LS zrdlxW4g5jExTXnOl{<}<;~>00JlzA^9lQdsjaOSp$X;XR14t7`qf7S&*#LXCIvlds z+xh{rFh9o=dxI&EI{YT}TY(VbZ?pLASGx_`|Be>;CmtFg`sBVKgWiVs9cpDIW?EWA=5H+H*%U2m37El}5t^N4I0hBv^Mc4@Y9S%66MF78 zw*=N_vF|kg>OixnFbo7u{Uh=0BB7+LP&1Tq1_+X)h5a{R>l_MR-`;mc?=|s^pM&|q z*nWH10)749aZlnzi4abo?S~?<3Z&~*jr~;Ccq^3Tq3G%hpuA;KdP)B?5v>XWq^hHv z5&c!Ra4Xvp_dipGd9v;yazP+2`l0E1kp3YHQt3lQ_9JpO=mSKUo9{DbD?%|5Xa5yQf1+f&CwGU z)S^yrSR|-W*;8|wV2?n%@6n->!J~!Dpr{qNE)Qy`nvixGYNIIJnH}^5`3dWWDX}5# z7Z7Hp@W&P)^BaEGc@supM=GtUD6O5fn+GZyZY|YK;fAKBJxeW1gTDlvUW~{E6_W}~ zqe;cT>{y`^0Gf)*P!(*q0#N0eX^wDq5x{Abr6zm|pqkeADJj@$g2l_aj#&MA0enfTbw22DraC3cKiY9T!D*W<>DisJ>Yy zkzbMf+^x=X_#1_ge;^W-@%a%tOrvvO>srUgFl_ePK*UOT%f6bj>B`E>6F@O1XJut| zNx9b#)_fc=Hu7pxmUxd2^iLGMUphdm4eb=c+iR5fbghVSDLLc(lWc0RvXLDZn7qa- zM-VZtM7MZ>BOQq+VB@SuGRonI5}2rA;tJmqr!KNEk4eHpU26IaeG z#}NPZm1h@CmdKAl@ThXaub>LiVDE^%&xFuJd{FzkICP2{P~97u@+SCUQ*^I_aZogy zf;6a~$P;q<;F2?Hz?;nCVT0LD4VU<@1m{Ocxs6kl+%Bwgo_MNKrv77G418pM%CL^jCrLoV=Y) zf)HP_$n(;{|Lstsq@RQo%Iq}ea&v*MUTeBEAtY?jy%sf>G z48g^ap&-3Dr~-K=TF3zVyHPCa1p#Gj2Fljy93Pmt6v{Ps1KEEDb{Y7XM3k7Iab`&2 zh4G4h7uY%rQeim_!vlj4tWALf9y=pogh>^-Kww1tXDlx;HS?WAhnbtg)1VSAC;#(6 zGyz1EEW}-A0eAV+rxQk+VvMd(7(hHK1I1J~ziogHs+!yo$#ExoCR^-i;o6Q>#}ug^ zjlG02%+wdh$nW|z0r)h*(LIK~lJ6|6L-m5?Q}iX+catpV3YrA52F4+2eO=P_&A?8V z>vM1Kb)l%8BSc+bTz?&D-aA7h=7K)X)lpNfy>qTuR-}T7*khKx!>!9;lX4Tq_D>W| z0F!zRCwI@x(5Rdca@?bf*LL&15VRh8NH@#k?B2oRRgj;*_R5ahO9TI#98AZ|@X95nw*FX`M6))`*NC!Lj@ZK-lW&nwJ=7)WG9s<5h ziuP&02i>OM!oh#<+>UksHp|_5>$lJC#HIgF0VSO5DYr!2k3NXRm*bL}4!u@yyq-Z&xTb&);4i?eHzwCkH=?ID|RP-3Aoa33o6PeqGlT z<|JPg-BZOelGM)?(^j7R{JOx~PBpbYHPFD=J2`+y`C7XQw3WromYfl$iZ*XUS>I}- VHvVkY-uaU0oVKA>zJ}ec{{gfS0CoTX literal 0 HcmV?d00001 diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts index 0506ebe5b..ea6f4c240 100644 --- a/apps/code/forge.config.ts +++ b/apps/code/forge.config.ts @@ -173,6 +173,13 @@ const config: ForgeConfig = { new MakerDMG({ icon: "./build/app-icon.icns", format: "ULFO", + background: "./build/dmg-background.png", + iconSize: 80, + window: { size: { width: 540, height: 380 } }, + contents: (opts) => [ + { x: 135, y: 225, type: "file", path: opts.appPath }, + { x: 405, y: 225, type: "link", path: "/Applications" }, + ], ...(shouldSignMacApp && appleCodesignIdentity ? { "code-sign": { From 79730b7e6b7a1a5e42c03c75b56becee395e64ab Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 06:29:19 -0700 Subject: [PATCH 29/39] feat: Revamped update banner (#1659) ## Problem Update notifications were easy to miss as ephemeral toasts and the "ready to install" state had no persistent UI. _The little gift icon animates / shakes periodically so they know something good is inside._ _This_ _is_ _the_ _most_ _important_ _part._ Closes https://github.com/PostHog/code/issues/1640 ![CleanShot 2026-04-15 at 15.56.07@2x.png](https://app.graphite.com/user-attachments/assets/f3c4cb1a-5f5f-4615-8b77-1d9a63b40fba.png) ## Changes 1. Replace toast-based UpdatePrompt with persistent UpdateBanner in sidebar 2. Add updateStore (Zustand) to manage update lifecycle state via tRPC subscriptions 3. Animate banner transitions between downloading, ready and installing states 4. Add border separator above ProjectSwitcher for visual consistency ## How did you test this? Manually --- apps/code/src/renderer/App.tsx | 8 +- .../src/renderer/components/UpdatePrompt.tsx | 278 ------------------ .../sidebar/components/SidebarContent.tsx | 4 +- .../sidebar/components/UpdateBanner.tsx | 109 +++++++ apps/code/src/renderer/stores/updateStore.ts | 112 +++++++ 5 files changed, 230 insertions(+), 281 deletions(-) delete mode 100644 apps/code/src/renderer/components/UpdatePrompt.tsx create mode 100644 apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx create mode 100644 apps/code/src/renderer/stores/updateStore.ts diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 89d5b148f..9f287722a 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -2,7 +2,6 @@ import { ErrorBoundary } from "@components/ErrorBoundary"; import { LoginTransition } from "@components/LoginTransition"; import { MainLayout } from "@components/MainLayout"; import { ScopeReauthPrompt } from "@components/ScopeReauthPrompt"; -import { UpdatePrompt } from "@components/UpdatePrompt"; import { AuthScreen } from "@features/auth/components/AuthScreen"; import { InviteCodeScreen } from "@features/auth/components/InviteCodeScreen"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; @@ -13,6 +12,7 @@ import { Flex, Spinner, Text } from "@radix-ui/themes"; import { initializeConnectivityStore } from "@renderer/stores/connectivityStore"; import { useFocusStore } from "@renderer/stores/focusStore"; import { useThemeStore } from "@renderer/stores/themeStore"; +import { initializeUpdateStore } from "@renderer/stores/updateStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useQueryClient } from "@tanstack/react-query"; @@ -49,6 +49,11 @@ function App() { return initializeConnectivityStore(); }, []); + // Initialize update store + useEffect(() => { + return initializeUpdateStore(); + }, []); + // Dev-only inbox demo command for local QA from the renderer console. useEffect(() => { if (import.meta.env.PROD) { @@ -218,7 +223,6 @@ function App() { onComplete={handleTransitionComplete} /> - ); diff --git a/apps/code/src/renderer/components/UpdatePrompt.tsx b/apps/code/src/renderer/components/UpdatePrompt.tsx deleted file mode 100644 index 3aaae9607..000000000 --- a/apps/code/src/renderer/components/UpdatePrompt.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { DownloadIcon } from "@phosphor-icons/react"; -import { Button, Card, Flex, Spinner, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; -import { useCallback, useRef, useState } from "react"; -import { toast as sonnerToast } from "sonner"; - -const log = logger.scope("updates"); -const UPDATE_TOAST_ID = "update-available"; -const CHECK_TOAST_ID = "update-check-status"; - -export function UpdatePrompt() { - const trpcReact = useTRPC(); - const { data: isEnabledData } = useQuery( - trpcReact.updates.isEnabled.queryOptions(), - ); - const isEnabled = isEnabledData?.enabled ?? false; - - const [isInstalling, setIsInstalling] = useState(false); - const toastShownRef = useRef(false); - - const checkMutation = useMutation(trpcReact.updates.check.mutationOptions()); - const installMutation = useMutation( - trpcReact.updates.install.mutationOptions(), - ); - - const handleRestart = useCallback(async () => { - if (isInstalling) { - return; - } - - setIsInstalling(true); - - try { - const result = await installMutation.mutateAsync(); - if (!result.installed) { - sonnerToast.dismiss(UPDATE_TOAST_ID); - sonnerToast.custom( - () => ( - - - - Update failed - - - Couldn't restart automatically. Please quit and relaunch - manually. - - - - ), - { duration: 5000 }, - ); - setIsInstalling(false); - } - } catch (error) { - log.error("Failed to install update", error); - sonnerToast.dismiss(UPDATE_TOAST_ID); - sonnerToast.custom( - () => ( - - - - Update failed - - - Update failed to install. Try quitting manually. - - - - ), - { duration: 5000 }, - ); - setIsInstalling(false); - } - }, [isInstalling, installMutation]); - - const handleLater = useCallback(() => { - sonnerToast.dismiss(UPDATE_TOAST_ID); - toastShownRef.current = false; - }, []); - - useSubscription( - trpcReact.updates.onReady.subscriptionOptions(undefined, { - enabled: isEnabled, - onData: (data) => { - // Dismiss any check status toast - sonnerToast.dismiss(CHECK_TOAST_ID); - - // Show persistent toast with action buttons - if (!toastShownRef.current) { - toastShownRef.current = true; - sonnerToast.custom( - () => ( - - - - - - - - - Update ready - - - {data.version - ? `Version ${data.version} has been downloaded and is ready to install.` - : "A new version of PostHog Code has been downloaded and is ready to install."} - - - - - - - - - - ), - { - id: UPDATE_TOAST_ID, - duration: Number.POSITIVE_INFINITY, - }, - ); - } - }, - }), - ); - - useSubscription( - trpcReact.updates.onStatus.subscriptionOptions(undefined, { - enabled: isEnabled, - onData: (status) => { - if (status.checking === false && status.error) { - // Show error toast - sonnerToast.custom( - () => ( - - - - Update check failed - - - {status.error} - - - - ), - { id: CHECK_TOAST_ID, duration: 4000 }, - ); - } else if (status.checking === false && status.upToDate) { - // Show up-to-date toast - const versionSuffix = status.version ? ` (v${status.version})` : ""; - sonnerToast.custom( - () => ( - - - - PostHog Code is up to date{versionSuffix} - - - - ), - { id: CHECK_TOAST_ID, duration: 3000 }, - ); - } else if (status.checking === true) { - // Show checking/downloading toast - sonnerToast.custom( - () => ( - - - - - {status.downloading - ? "Downloading update..." - : "Checking for updates..."} - - - - ), - { id: CHECK_TOAST_ID, duration: Number.POSITIVE_INFINITY }, - ); - } - }, - }), - ); - - useSubscription( - trpcReact.updates.onCheckFromMenu.subscriptionOptions(undefined, { - enabled: isEnabled, - onData: async () => { - // Show checking toast immediately - sonnerToast.custom( - () => ( - - - - - Checking for updates... - - - - ), - { id: CHECK_TOAST_ID, duration: Number.POSITIVE_INFINITY }, - ); - - try { - const result = await checkMutation.mutateAsync(); - - if (!result.success && result.errorCode !== "already_checking") { - sonnerToast.custom( - () => ( - - - - Update check failed - - - {result.errorMessage || "Failed to check for updates"} - - - - ), - { id: CHECK_TOAST_ID, duration: 4000 }, - ); - } - } catch (error) { - log.error("Failed to check for updates:", error); - sonnerToast.custom( - () => ( - - - - Update check failed - - - An unexpected error occurred - - - - ), - { id: CHECK_TOAST_ID, duration: 4000 }, - ); - } - }, - }), - ); - - if (!isEnabled) { - return null; - } - - return null; -} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx index b894e5b46..42cabf8c5 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx @@ -5,6 +5,7 @@ import { useNavigationStore } from "@stores/navigationStore"; import type React from "react"; import { ProjectSwitcher } from "./ProjectSwitcher"; import { SidebarMenu } from "./SidebarMenu"; +import { UpdateBanner } from "./UpdateBanner"; export const SidebarContent: React.FC = () => { const archivedTaskIds = useArchivedTaskIds(); @@ -17,6 +18,7 @@ export const SidebarContent: React.FC = () => { + {archivedTaskIds.size > 0 && ( )} - + diff --git a/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx b/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx new file mode 100644 index 000000000..2262e4d5d --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx @@ -0,0 +1,109 @@ +import { ArrowsClockwise, Gift, Spinner } from "@phosphor-icons/react"; +import { Box } from "@radix-ui/themes"; +import { useUpdateStore } from "@stores/updateStore"; +import { AnimatePresence, motion } from "framer-motion"; + +export function UpdateBanner() { + const status = useUpdateStore((s) => s.status); + const version = useUpdateStore((s) => s.version); + const isEnabled = useUpdateStore((s) => s.isEnabled); + const installUpdate = useUpdateStore((s) => s.installUpdate); + + const isVisible = + isEnabled && + (status === "downloading" || status === "ready" || status === "installing"); + + return ( + + {isVisible && ( + + + {status === "downloading" && ( + + + Downloading update... + + )} + + {status === "ready" && ( + + +
+ + + +
+ + {version ? `v${version} ready` : "Update ready"} + + + Restart to apply + +
+ +
+
+
+ )} + + {status === "installing" && ( + + + Restarting... + + )} +
+
+ )} +
+ ); +} + +function BannerContent({ + children, + ...props +}: { children: React.ReactNode } & React.ComponentProps) { + return ( + +
+ {children} +
+
+ ); +} diff --git a/apps/code/src/renderer/stores/updateStore.ts b/apps/code/src/renderer/stores/updateStore.ts new file mode 100644 index 000000000..084db3257 --- /dev/null +++ b/apps/code/src/renderer/stores/updateStore.ts @@ -0,0 +1,112 @@ +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { create } from "zustand"; + +const log = logger.scope("update-store"); + +type UpdateStatus = + | "idle" + | "checking" + | "downloading" + | "ready" + | "installing"; + +interface UpdateState { + status: UpdateStatus; + version: string | null; + isEnabled: boolean; + + installUpdate: () => Promise; + checkForUpdates: () => void; +} + +export const useUpdateStore = create()((set, get) => ({ + status: "idle", + version: null, + isEnabled: false, + + installUpdate: async () => { + if (get().status === "installing") return; + + set({ status: "installing" }); + + try { + const result = await trpcClient.updates.install.mutate(); + if (!result.installed) { + log.error("Update install returned not installed"); + set({ status: "ready" }); + } + } catch (error) { + log.error("Failed to install update", { error }); + set({ status: "ready" }); + } + }, + + checkForUpdates: () => { + trpcClient.updates.check.mutate().catch((error: unknown) => { + log.error("Failed to check for updates", { error }); + }); + }, +})); + +export function initializeUpdateStore() { + trpcClient.updates.isEnabled + .query() + .then((result) => { + useUpdateStore.setState({ isEnabled: result.enabled }); + }) + .catch((error: unknown) => { + log.error("Failed to get update enabled status", { error }); + }); + + const statusSub = trpcClient.updates.onStatus.subscribe(undefined, { + onData: (status) => { + if (status.checking && status.downloading) { + useUpdateStore.setState({ status: "downloading" }); + } else if (status.checking) { + useUpdateStore.setState({ status: "checking" }); + } else if (status.upToDate) { + const current = useUpdateStore.getState().status; + if (current === "checking" || current === "downloading") { + useUpdateStore.setState({ status: "idle" }); + } + } else if (status.error) { + log.error("Update check failed", { error: status.error }); + const current = useUpdateStore.getState().status; + if (current === "checking" || current === "downloading") { + useUpdateStore.setState({ status: "idle" }); + } + } + }, + onError: (error) => { + log.error("Update status subscription error", { error }); + }, + }); + + const readySub = trpcClient.updates.onReady.subscribe(undefined, { + onData: (data) => { + useUpdateStore.setState({ + status: "ready", + version: data.version, + }); + }, + onError: (error) => { + log.error("Update ready subscription error", { error }); + }, + }); + + const menuCheckSub = trpcClient.updates.onCheckFromMenu.subscribe(undefined, { + onData: () => { + useUpdateStore.getState().checkForUpdates(); + }, + onError: (error) => { + log.error("Update menu check subscription error", { error }); + }, + }); + + return () => { + statusSub.unsubscribe(); + readySub.unsubscribe(); + menuCheckSub.unsubscribe(); + }; +} From a076c1e0d53e07ffb5c3b19465bd5ec08dd3bf5b Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 16 Apr 2026 17:17:05 +0300 Subject: [PATCH 30/39] feat(sig): Add user autonomy config, refactor API (#1672) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: oliverb123 <8105072+oliverb123@users.noreply.github.com> --- apps/code/src/renderer/api/posthogClient.ts | 172 ++++++++++++++++-- .../inbox/components/SignalSourceToggles.tsx | 32 +++- .../components/detail/ReportTaskLogs.tsx | 43 ++++- .../inbox/components/list/ReportListRow.tsx | 10 - .../components/utils/ReportCardContent.tsx | 29 +-- .../inbox/devtools/inboxDemoConsole.ts | 3 - .../inbox/hooks/useSignalSourceManager.ts | 102 +++++++++-- .../inbox/hooks/useSignalTeamConfig.ts | 23 +++ .../hooks/useSignalUserAutonomyConfig.ts | 23 +++ .../inbox/utils/buildSignalTaskPrompt.ts | 2 +- .../inbox/utils/filterReports.test.ts | 1 - .../sections/SignalSourcesSettings.tsx | 95 ++++++++-- apps/code/src/shared/types.ts | 24 ++- 13 files changed, 445 insertions(+), 114 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/hooks/useSignalTeamConfig.ts create mode 100644 apps/code/src/renderer/features/inbox/hooks/useSignalUserAutonomyConfig.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 5daae8e31..a1e4e6451 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -15,6 +15,9 @@ import type { SignalReportStatus, SignalReportsQueryParams, SignalReportsResponse, + SignalReportTask, + SignalTeamConfig, + SignalUserAutonomyConfig, SuggestedReviewersArtefact, Task, TaskRun, @@ -412,7 +415,7 @@ export class PostHogAPIClient { async listSignalSourceConfigs( projectId: number, ): Promise { - const urlPath = `/api/projects/${projectId}/signal_source_configs/`; + const urlPath = `/api/projects/${projectId}/signals/source_configs/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); const response = await this.api.fetcher.fetch({ method: "get", @@ -439,7 +442,7 @@ export class PostHogAPIClient { config?: Record; }, ): Promise { - const urlPath = `/api/projects/${projectId}/signal_source_configs/`; + const urlPath = `/api/projects/${projectId}/signals/source_configs/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); const response = await this.api.fetcher.fetch({ method: "post", @@ -466,7 +469,7 @@ export class PostHogAPIClient { configId: string, updates: { enabled: boolean }, ): Promise { - const urlPath = `/api/projects/${projectId}/signal_source_configs/${configId}/`; + const urlPath = `/api/projects/${projectId}/signals/source_configs/${configId}/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); const response = await this.api.fetcher.fetch({ method: "patch", @@ -1212,7 +1215,7 @@ export class PostHogAPIClient { ): Promise { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/`, ); if (params?.limit != null) { @@ -1237,7 +1240,7 @@ export class PostHogAPIClient { const response = await this.api.fetcher.fetch({ method: "get", url, - path: `/api/projects/${teamId}/signal_reports/`, + path: `/api/projects/${teamId}/signals/reports/`, }); if (!response.ok) { @@ -1254,9 +1257,9 @@ export class PostHogAPIClient { async getSignalProcessingState(): Promise { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_processing/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/processing/`, ); - const path = `/api/projects/${teamId}/signal_processing/`; + const path = `/api/projects/${teamId}/signals/processing/`; const response = await this.api.fetcher.fetch({ method: "get", @@ -1282,9 +1285,9 @@ export class PostHogAPIClient { ): Promise { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/available_reviewers/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/available_reviewers/`, ); - const path = `/api/projects/${teamId}/signal_reports/available_reviewers/`; + const path = `/api/projects/${teamId}/signals/reports/available_reviewers/`; if (query?.trim()) { url.searchParams.set("query", query.trim()); @@ -1311,12 +1314,12 @@ export class PostHogAPIClient { try { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/signals/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/signals/`, ); const response = await this.api.fetcher.fetch({ method: "get", url, - path: `/api/projects/${teamId}/signal_reports/${reportId}/signals/`, + path: `/api/projects/${teamId}/signals/reports/${reportId}/signals/`, }); if (!response.ok) { @@ -1343,9 +1346,9 @@ export class PostHogAPIClient { ): Promise { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/artefacts/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`, ); - const path = `/api/projects/${teamId}/signal_reports/${reportId}/artefacts/`; + const path = `/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`; try { const response = await this.api.fetcher.fetch({ @@ -1410,9 +1413,9 @@ export class PostHogAPIClient { ): Promise { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/state/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/state/`, ); - const path = `/api/projects/${teamId}/signal_reports/${reportId}/state/`; + const path = `/api/projects/${teamId}/signals/reports/${reportId}/state/`; const response = await this.api.fetcher.fetch({ method: "post", @@ -1437,9 +1440,9 @@ export class PostHogAPIClient { }> { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/`, ); - const path = `/api/projects/${teamId}/signal_reports/${reportId}/`; + const path = `/api/projects/${teamId}/signals/reports/${reportId}/`; const response = await this.api.fetcher.fetch({ method: "delete", @@ -1464,9 +1467,9 @@ export class PostHogAPIClient { }> { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/reingest/`, + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/reingest/`, ); - const path = `/api/projects/${teamId}/signal_reports/${reportId}/reingest/`; + const path = `/api/projects/${teamId}/signals/reports/${reportId}/reingest/`; const response = await this.api.fetcher.fetch({ method: "post", @@ -1485,6 +1488,137 @@ export class PostHogAPIClient { }; } + async getSignalReportTasks( + reportId: string, + options?: { relationship?: SignalReportTask["relationship"] }, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/tasks/`, + ); + if (options?.relationship) { + url.searchParams.set("relationship", options.relationship); + } + const path = `/api/projects/${teamId}/signals/reports/${reportId}/tasks/`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch signal report tasks: ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.results ?? []; + } + + async getSignalTeamConfig(): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/config/`, + ); + const path = `/api/projects/${teamId}/signals/config/`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch signal team config: ${response.statusText}`, + ); + } + + return (await response.json()) as SignalTeamConfig; + } + + async updateSignalTeamConfig(updates: { + default_autostart_priority: string; + }): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/config/`, + ); + const path = `/api/projects/${teamId}/signals/config/`; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(updates), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to update signal team config: ${response.statusText}`, + ); + } + + return (await response.json()) as SignalTeamConfig; + } + + async getSignalUserAutonomyConfig(): Promise { + const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); + const path = "/api/users/@me/signal_autonomy/"; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + return (await response.json()) as SignalUserAutonomyConfig; + } + + async updateSignalUserAutonomyConfig(updates: { + autostart_priority: string | null; + }): Promise { + const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); + const path = "/api/users/@me/signal_autonomy/"; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(updates), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to update signal user autonomy config: ${response.statusText}`, + ); + } + return (await response.json()) as SignalUserAutonomyConfig; + } + + async deleteSignalUserAutonomyConfig(): Promise { + const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); + const path = "/api/users/@me/signal_autonomy/"; + + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to delete signal user autonomy config: ${response.statusText}`, + ); + } + } + async getMcpServers(): Promise { const teamId = await this.getTeamId(); const url = new URL( diff --git a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx b/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx index 96e5cc44d..eea1e8334 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx +++ b/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx @@ -47,6 +47,24 @@ interface SignalSourceToggleCardProps { onSetup?: () => void; loading?: boolean; statusSection?: React.ReactNode; + syncStatus?: string | null; +} + +function syncStatusLabel(status: string | null | undefined): { + text: string; + color: string; +} | null { + if (!status) return null; + switch (status) { + case "running": + return { text: "Syncing…", color: "var(--amber-11)" }; + case "completed": + return { text: "Synced", color: "var(--green-11)" }; + case "failed": + return { text: "Sync failed", color: "var(--red-11)" }; + default: + return null; + } } const SignalSourceToggleCard = memo(function SignalSourceToggleCard({ @@ -61,7 +79,10 @@ const SignalSourceToggleCard = memo(function SignalSourceToggleCard({ onSetup, loading, statusSection, + syncStatus, }: SignalSourceToggleCardProps) { + const statusInfo = checked ? syncStatusLabel(syncStatus) : null; + return ( {labelSuffix} + {statusInfo && ( + + {statusInfo.text} + + )} {description} @@ -276,7 +302,7 @@ interface SignalSourceTogglesProps { sourceStates?: Partial< Record< keyof SignalSourceValues, - { requiresSetup: boolean; loading: boolean } + { requiresSetup: boolean; loading: boolean; syncStatus?: string | null } > >; sessionAnalysisStatus?: SignalSourceConfig["status"]; @@ -334,6 +360,7 @@ export function SignalSourceToggles({ checked={value.error_tracking} onCheckedChange={toggleErrorTracking} disabled={disabled} + syncStatus={sourceStates?.error_tracking?.syncStatus} /> } @@ -386,6 +413,7 @@ export function SignalSourceToggles({ requiresSetup={sourceStates?.github?.requiresSetup} onSetup={setupGithub} loading={sourceStates?.github?.loading} + syncStatus={sourceStates?.github?.syncStatus} /> } @@ -397,6 +425,7 @@ export function SignalSourceToggles({ requiresSetup={sourceStates?.linear?.requiresSetup} onSetup={setupLinear} loading={sourceStates?.linear?.loading} + syncStatus={sourceStates?.linear?.syncStatus} /> } @@ -408,6 +437,7 @@ export function SignalSourceToggles({ requiresSetup={sourceStates?.zendesk?.requiresSetup} onSetup={setupZendesk} loading={sourceStates?.zendesk?.loading} + syncStatus={sourceStates?.zendesk?.syncStatus} /> ); diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx index 69b836032..9134f890b 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx @@ -7,21 +7,41 @@ import { XCircleIcon, } from "@phosphor-icons/react"; import { Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; -import type { SignalReportStatus, Task } from "@shared/types"; +import type { SignalReportStatus, SignalReportTask, Task } from "@shared/types"; import { useState } from "react"; -function useReportTask(reportId: string) { - return useAuthenticatedQuery( +const RELATIONSHIP_LABELS: Record = { + repo_selection: "Repository selection", + research: "Research task", + implementation: "Implementation task", +}; + +interface ReportTaskData { + task: Task; + relationship: SignalReportTask["relationship"]; +} + +function useReportTask(reportId: string, reportStatus: SignalReportStatus) { + const isActive = + reportStatus === "candidate" || + reportStatus === "in_progress" || + reportStatus === "pending_input"; + + return useAuthenticatedQuery( ["inbox", "report-task", reportId], async (client) => { - const tasks = (await client.getTasks({ - originProduct: "signal_report", - })) as unknown as Task[]; - return tasks.find((t) => t.signal_report === reportId) ?? null; + const reportTasks = await client.getSignalReportTasks(reportId, { + relationship: "research", + }); + const match = reportTasks[0]; + if (!match) return null; + const task = await client.getTask(match.task_id); + return { task, relationship: match.relationship }; }, { enabled: !!reportId, - staleTime: 10_000, + staleTime: isActive ? 5_000 : 10_000, + refetchInterval: isActive ? 5_000 : false, }, ); } @@ -80,9 +100,12 @@ export function ReportTaskLogs({ reportId, reportStatus, }: ReportTaskLogsProps) { - const { data: task, isLoading } = useReportTask(reportId); + const { data, isLoading } = useReportTask(reportId, reportStatus); const [expanded, setExpanded] = useState(false); + const task = data?.task ?? null; + const relationship = data?.relationship ?? null; + const showBar = isLoading || !!task || @@ -190,7 +213,7 @@ export function ReportTaskLogs({ > {status.icon} - Research task + {relationship ? RELATIONSHIP_LABELS[relationship] : "Research task"} - ) : sourceProductMeta ? ( - - - ) : ( diff --git a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx index ab50dcb44..0072873f7 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx +++ b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx @@ -2,8 +2,7 @@ import { SignalReportActionabilityBadge } from "@features/inbox/components/utils import { SignalReportPriorityBadge } from "@features/inbox/components/utils/SignalReportPriorityBadge"; import { SignalReportStatusBadge } from "@features/inbox/components/utils/SignalReportStatusBadge"; import { SignalReportSummaryMarkdown } from "@features/inbox/components/utils/SignalReportSummaryMarkdown"; -import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; -import { EyeIcon, LightningIcon, UsersIcon } from "@phosphor-icons/react"; +import { EyeIcon, LightningIcon } from "@phosphor-icons/react"; import { Badge, Flex, Text, Tooltip } from "@radix-ui/themes"; import type { SignalReport } from "@shared/types"; @@ -24,25 +23,9 @@ export function ReportCardContent({ { month: "short", day: "numeric" }, ); - const firstProduct = (report.source_products ?? [])[0]; - const sourceProductMeta = firstProduct - ? SOURCE_PRODUCT_META[firstProduct] - : null; - return ( - {sourceProductMeta && ( - - - - - - )} - - {report.relevant_user_count != null && - report.relevant_user_count > 0 && ( - - - - {report.relevant_user_count} user - {report.relevant_user_count !== 1 ? "s" : ""} - - - )} {updatedAtLabel} diff --git a/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts b/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts index f0d353c59..afa9876e1 100644 --- a/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts +++ b/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts @@ -35,7 +35,6 @@ function getDemoReports(): SignalReport[] { status: "ready", total_weight: 79, signal_count: 31, - relevant_user_count: 12, created_at: iso(1800), updated_at: iso(8), artefact_count: 3, @@ -48,7 +47,6 @@ function getDemoReports(): SignalReport[] { status: "ready", total_weight: 52, signal_count: 11, - relevant_user_count: 6, created_at: iso(2200), updated_at: iso(35), artefact_count: 2, @@ -61,7 +59,6 @@ function getDemoReports(): SignalReport[] { status: "ready", total_weight: 24, signal_count: 4, - relevant_user_count: 3, created_at: iso(3600), updated_at: iso(140), artefact_count: 1, diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts index 026b7fa86..ea38e740e 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts @@ -12,6 +12,8 @@ import { toast } from "sonner"; import { useEvaluations } from "./useEvaluations"; import { useExternalDataSources } from "./useExternalDataSources"; import { useSignalSourceConfigs } from "./useSignalSourceConfigs"; +import { useSignalTeamConfig } from "./useSignalTeamConfig"; +import { useSignalUserAutonomyConfig } from "./useSignalUserAutonomyConfig"; type SourceProduct = SignalSourceConfig["source_product"]; type SourceType = SignalSourceConfig["source_type"]; @@ -100,6 +102,8 @@ export function useSignalSourceManager() { const { data: externalSources, isLoading: sourcesLoading } = useExternalDataSources(); const { data: evaluations } = useEvaluations(); + const { data: teamConfig } = useSignalTeamConfig(); + const { data: userAutonomyConfig } = useSignalUserAutonomyConfig(); // Optimistic overrides keyed by source product — only sources actively being // toggled get an entry, so unrelated sources never see a prop change. @@ -134,15 +138,6 @@ export function useSignalSourceManager() { [configs], ); - const sessionAnalysisStatus = useMemo(() => { - const config = configs?.find( - (c) => - c.source_product === "session_replay" && - c.source_type === "session_analysis_cluster", - ); - return config?.status ?? null; - }, [configs]); - // Merge: optimistic overrides take precedence over server values. const displayValues = useMemo(() => { if (Object.keys(optimistic).length === 0) return serverValues; @@ -153,19 +148,38 @@ export function useSignalSourceManager() { const states: Partial< Record< keyof SignalSourceValues, - { requiresSetup: boolean; loading: boolean } + { + requiresSetup: boolean; + loading: boolean; + syncStatus?: string | null; + } > > = {}; - for (const product of ["github", "linear", "zendesk"] as const) { - const hasExternalSource = !!findExternalSource(product); - const isEnabled = serverValues[product]; - states[product] = { - requiresSetup: !hasExternalSource && !isEnabled, - loading: !!loadingSources[product], - }; + for (const product of ALL_SOURCE_PRODUCTS) { + if ( + product === "github" || + product === "linear" || + product === "zendesk" + ) { + const hasExternalSource = !!findExternalSource(product); + const isEnabled = serverValues[product]; + const config = configs?.find((c) => c.source_product === product); + states[product] = { + requiresSetup: !hasExternalSource && !isEnabled, + loading: !!loadingSources[product], + syncStatus: config?.status ?? null, + }; + } else { + const config = configs?.find((c) => c.source_product === product); + states[product] = { + requiresSetup: false, + loading: false, + syncStatus: config?.status ?? null, + }; + } } return states; - }, [findExternalSource, serverValues, loadingSources]); + }, [findExternalSource, serverValues, loadingSources, configs]); const evaluationsUrl = useMemo(() => { if (!cloudRegion) return ""; @@ -406,10 +420,56 @@ export function useSignalSourceManager() { setSetupSource(null); }, []); + const handleUpdateAutostartPriority = useCallback( + async (priority: string) => { + if (!client) return; + try { + await client.updateSignalTeamConfig({ + default_autostart_priority: priority, + }); + await queryClient.invalidateQueries({ + queryKey: ["signals", "team-config"], + }); + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to update autostart priority"; + toast.error(message); + } + }, + [client, queryClient], + ); + + const handleUpdateUserAutonomyPriority = useCallback( + async (priority: string | null) => { + if (!client) return; + try { + if (priority === null) { + await client.deleteSignalUserAutonomyConfig(); + } else { + await client.updateSignalUserAutonomyConfig({ + autostart_priority: priority, + }); + } + await queryClient.invalidateQueries({ + queryKey: ["signals", "user-autonomy-config"], + }); + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to update autonomy setting"; + toast.error(message); + } + }, + [client, queryClient], + ); + return { displayValues, sourceStates, - sessionAnalysisStatus, + setupSource, isLoading, handleToggle, @@ -419,5 +479,9 @@ export function useSignalSourceManager() { evaluations: displayEvaluations, evaluationsUrl, handleToggleEvaluation, + teamConfig, + handleUpdateAutostartPriority, + userAutonomyConfig, + handleUpdateUserAutonomyPriority, }; } diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalTeamConfig.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalTeamConfig.ts new file mode 100644 index 000000000..1183d82de --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalTeamConfig.ts @@ -0,0 +1,23 @@ +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +import type { SignalTeamConfig } from "@shared/types"; + +export function useSignalTeamConfig(options?: { + enabled?: boolean; + staleTime?: number; +}) { + return useAuthenticatedQuery( + ["signals", "team-config"], + async (client) => { + try { + return await client.getSignalTeamConfig(); + } catch { + // Team config may not exist yet + return null; + } + }, + { + enabled: options?.enabled ?? true, + staleTime: options?.staleTime ?? 30_000, + }, + ); +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalUserAutonomyConfig.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalUserAutonomyConfig.ts new file mode 100644 index 000000000..39b29fde5 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalUserAutonomyConfig.ts @@ -0,0 +1,23 @@ +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +import type { SignalUserAutonomyConfig } from "@shared/types"; + +export function useSignalUserAutonomyConfig(options?: { + enabled?: boolean; + staleTime?: number; +}) { + return useAuthenticatedQuery( + ["signals", "user-autonomy-config"], + async (client) => { + try { + return await client.getSignalUserAutonomyConfig(); + } catch { + // 404 when user has opted out (no config record) + return null; + } + }, + { + enabled: options?.enabled ?? true, + staleTime: options?.staleTime ?? 30_000, + }, + ); +} diff --git a/apps/code/src/renderer/features/inbox/utils/buildSignalTaskPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildSignalTaskPrompt.ts index eb6144a66..a519bb31f 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildSignalTaskPrompt.ts +++ b/apps/code/src/renderer/features/inbox/utils/buildSignalTaskPrompt.ts @@ -23,7 +23,7 @@ export function buildSignalTaskPrompt({ "", summary, "", - `**Signal strength:** ${report.signal_count} occurrences, ${report.relevant_user_count ?? 0} affected users`, + `**Signal strength:** ${report.signal_count} occurrences`, ]; if (signals.length > 0) { diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts index 0bb61db6f..6042daec0 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts +++ b/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts @@ -14,7 +14,6 @@ function makeReport(overrides: Partial = {}): SignalReport { status: "ready", total_weight: 50, signal_count: 10, - relevant_user_count: 5, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-02T00:00:00Z", artefact_count: 3, diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx index 53fe39ce9..b790d747a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx @@ -1,14 +1,28 @@ import { DataSourceSetup } from "@features/inbox/components/DataSourceSetup"; import { SignalSourceToggles } from "@features/inbox/components/SignalSourceToggles"; import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; -import { useMeQuery } from "@hooks/useMeQuery"; -import { Flex, Text } from "@radix-ui/themes"; +import { Box, Flex, Select, Text } from "@radix-ui/themes"; +import type { SignalReportPriority } from "@shared/types"; + +const PRIORITY_OPTIONS: { value: SignalReportPriority; label: string }[] = [ + { value: "P0", label: "P0 — Critical only" }, + { value: "P1", label: "P1 — High and above" }, + { value: "P2", label: "P2 — Medium and above" }, + { value: "P3", label: "P3 — Low and above" }, + { value: "P4", label: "P4 — All priorities" }, +]; + +const NEVER_VALUE = "__never__"; + +const USER_PRIORITY_OPTIONS: { value: string; label: string }[] = [ + ...PRIORITY_OPTIONS, + { value: NEVER_VALUE, label: "Never — opt out of auto-assigned tasks" }, +]; export function SignalSourcesSettings() { const { displayValues, sourceStates, - sessionAnalysisStatus, setupSource, isLoading, handleToggle, @@ -18,9 +32,9 @@ export function SignalSourcesSettings() { evaluations, evaluationsUrl, handleToggleEvaluation, + userAutonomyConfig, + handleUpdateUserAutonomyPriority, } = useSignalSourceManager(); - const { data: me } = useMeQuery(); - const isStaff = me?.is_staff ?? false; if (isLoading) { return ( @@ -30,6 +44,9 @@ export function SignalSourcesSettings() { ); } + const userPriorityValue = + userAutonomyConfig?.autostart_priority ?? NEVER_VALUE; + return ( @@ -44,20 +61,60 @@ export function SignalSourcesSettings() { onCancel={handleSetupCancel} /> ) : ( - void handleToggle(source, enabled)} - sourceStates={sourceStates} - sessionAnalysisStatus={sessionAnalysisStatus} - onSetup={handleSetup} - evaluations={isStaff ? evaluations : undefined} - evaluationsUrl={isStaff ? evaluationsUrl : undefined} - onToggleEvaluation={ - isStaff - ? (id, enabled) => void handleToggleEvaluation(id, enabled) - : undefined - } - /> + <> + void handleToggle(source, enabled)} + sourceStates={sourceStates} + onSetup={handleSetup} + evaluations={evaluations} + evaluationsUrl={evaluationsUrl} + onToggleEvaluation={(id, enabled) => + void handleToggleEvaluation(id, enabled) + } + /> + + + + + Your auto-start threshold + + + Automatically start tasks assigned to you for reports at or + above this priority. Choose "Never" to opt out + entirely. + + + void handleUpdateUserAutonomyPriority( + value === NEVER_VALUE ? null : value, + ) + } + > + + + {USER_PRIORITY_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + + + )} ); diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 8869b996d..ecd9077a6 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -267,7 +267,6 @@ export interface SignalReport { total_weight: number; signal_count: number; signals_at_run?: number; - relevant_user_count: number | null; created_at: string; updated_at: string; artefact_count: number; @@ -279,8 +278,6 @@ export interface SignalReport { already_addressed?: boolean | null; /** Whether the current user is a suggested reviewer for this report (server-annotated). */ is_suggested_reviewer?: boolean; - /** Distinct source products contributing signals to this report (e.g. "session_replay", "error_tracking"). */ - source_products?: string[]; } export interface SignalReportArtefactContent { @@ -459,3 +456,24 @@ export interface SignalReportsQueryParams { /** Comma-separated PostHog user UUIDs — only returns reports with these suggested reviewers. */ suggested_reviewers?: string; } + +export interface SignalReportTask { + id: string; + relationship: "repo_selection" | "research" | "implementation"; + task_id: string; + created_at: string; +} + +export interface SignalTeamConfig { + id: string; + default_autostart_priority: SignalReportPriority; + created_at: string; + updated_at: string; +} + +export interface SignalUserAutonomyConfig { + id?: string; + autostart_priority: SignalReportPriority | null; + created_at?: string; + updated_at?: string; +} From 92f845a825547d35b90986811082ca5095ca20b1 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 15:46:04 +0100 Subject: [PATCH 31/39] WIP - Address some beta testing feedback and add an overlay for local tasks --- apps/mobile/app.json | 3 +- apps/mobile/src/app/(tabs)/tasks.tsx | 6 +- apps/mobile/src/app/task/[id].tsx | 149 +++++++++++--- apps/mobile/src/app/task/index.tsx | 191 ++++++++++-------- .../features/tasks/stores/taskSessionStore.ts | 13 ++ 5 files changed, 249 insertions(+), 113 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 305a63002..78cd9d092 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -21,7 +21,8 @@ "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", "NSPhotoLibraryUsageDescription": "Allow PostHog to access your photos for attaching images to tasks", - "ITSAppUsesNonExemptEncryption": false + "ITSAppUsesNonExemptEncryption": false, + "LSApplicationQueriesSchemes": ["posthog-code"] } }, "android": { diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index 1e984eb17..5e2b7226d 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -24,9 +24,11 @@ export default function TasksScreen() { }, []), ); - const handleCreateTask = () => { + const handleCreateTask = useCallback(() => { + if (!readyRef.current) return; + readyRef.current = false; router.push("/task"); - }; + }, [router]); const handleTaskPress = useCallback( (taskId: string) => { diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 7a3ddc18d..360865d11 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,8 +1,14 @@ import { Text } from "@components/text"; import * as Haptics from "expo-haptics"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Alert, Pressable, View } from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Linking, + Pressable, + View, +} from "react-native"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -176,6 +182,87 @@ export default function TaskDetailScreen() { [router], ); + // Stale detection for local tasks: if no new S3 data arrives for 30s + // while the agent is supposedly working, the desktop may be offline. + const isLocal = task?.latest_run?.environment === "local"; + const [isStale, setIsStale] = useState(false); + useEffect(() => { + if (!isLocal || !session?.isPromptPending) { + setIsStale(false); + return; + } + const interval = setInterval(() => { + const lastEvent = session.lastEventAt ?? 0; + setIsStale(lastEvent > 0 && Date.now() - lastEvent > 30_000); + }, 5_000); + return () => clearInterval(interval); + }, [isLocal, session?.isPromptPending, session?.lastEventAt]); + + const handleOpenOnDesktop = useCallback(async () => { + if (!taskId) return; + const url = `posthog-code://task/${taskId}`; + try { + await Linking.openURL(url); + } catch { + Alert.alert( + "Desktop app not found", + "Install PostHog Code on your Mac to open tasks locally.", + ); + } + }, [taskId]); + + const handleContinueInCloud = useCallback(async () => { + if (!taskId || !task) return; + try { + setRetrying(true); + disconnectFromTask(taskId); + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + }); + setTask(updatedTask); + await connectToTask(updatedTask); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (err) { + console.error("Failed to continue in cloud:", err); + setRetrying(false); + Alert.alert( + "Failed to switch", + "Could not continue this task in the cloud. Please try again.", + ); + } + }, [taskId, task, disconnectFromTask, connectToTask]); + + const environment = task?.latest_run?.environment; + + const visibleAgentTypes = [ + "agent_message_chunk", + "agent_message", + "agent_thought_chunk", + "tool_call", + ]; + const hasAnyAgentOutput = + session?.events.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as Record)?.update; + return visibleAgentTypes.includes( + (su as Record)?.sessionUpdate as string, + ); + }) ?? false; + + const isConnecting = + retrying || (!!session?.awaitingAgentOutput && !hasAnyAgentOutput); + const isThinking = !!session?.awaitingAgentOutput && hasAnyAgentOutput; + + // Haptic pulse when connecting/thinking indicators dismiss + const prevWaiting = useRef(false); + useEffect(() => { + const waiting = isConnecting || isThinking; + if (prevWaiting.current && !waiting) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + prevWaiting.current = waiting; + }, [isConnecting, isThinking]); + if (error || (!task && !loading)) { return ( <> @@ -204,23 +291,6 @@ export default function TaskDetailScreen() { ); } - const environment = task?.latest_run?.environment; - - const visibleAgentTypes = [ - "agent_message_chunk", - "agent_message", - "agent_thought_chunk", - "tool_call", - ]; - const hasAnyAgentOutput = - session?.events.some((e) => { - if (e.type !== "session_update") return false; - const su = (e.notification as Record)?.update; - return visibleAgentTypes.includes( - (su as Record)?.sessionUpdate as string, - ); - }) ?? false; - return ( <> )} + {/* Local task banner */} + {isLocal && !session?.terminalStatus && !loading && ( + + + + {isStale ? "Desktop may be offline" : "Running on your desktop"} + + + + + Open on Desktop + + + + + Continue in Cloud + + + + + + )} + {/* Fixed input at bottom — hidden when run is terminal */} {!session?.terminalStatus && ( )} diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 1f4aaf2db..3cf60940a 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -4,8 +4,11 @@ import * as WebBrowser from "expo-web-browser"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, - FlatList, + Keyboard, + KeyboardAvoidingView, + Platform, Pressable, + ScrollView, TextInput, View, } from "react-native"; @@ -162,99 +165,113 @@ export default function NewTaskScreen() { presentation: "modal", }} /> - - {loadingRepos ? ( - - - - Loading repositories... - - - ) : !hasGithubIntegration ? ( - - ) : ( - <> - Repository - - - {filteredRepositories.length === 0 ? ( - - - {repoSearch - ? `No repositories match "${repoSearch}"` - : "No repositories available"} - - - ) : ( - item} + + + + {loadingRepos ? ( + + + + Loading repositories... + + + ) : !hasGithubIntegration ? ( + + ) : ( + <> + Repository + + ( - setSelectedRepo(item)} - className={`border-gray-6 border-b px-3 py-3 ${ - selectedRepo === item ? "bg-accent-3" : "" - }`} - > - + {filteredRepositories.length === 0 ? ( + + + {repoSearch + ? `No repositories match "${repoSearch}"` + : "No repositories available"} + + + ) : ( + filteredRepositories.map((item) => ( + setSelectedRepo(item)} + className={`border-gray-6 border-b px-3 py-3 ${ + selectedRepo === item ? "bg-accent-3" : "" }`} > - {item} - - + + {item} + + + )) )} - /> - )} - + - Task description - - - - {creating ? ( - + Task description + + - ) : ( - - Create task - - )} - - - )} -
+ {creating ? ( + + ) : ( + + Create task + + )} + + + )} + + + ); } diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 1689e8ffe..ccddd9438 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,3 +1,4 @@ +import * as Haptics from "expo-haptics"; import { create } from "zustand"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; @@ -85,6 +86,12 @@ export interface TaskSession { // True after a user prompt is sent, cleared when the first piece of // agent output (tool call, message, etc.) arrives from polling. awaitingAgentOutput?: boolean; + // Messages queued while the agent is working. Auto-sent when control + // returns (isPromptPending flips to false). + messageQueue?: string[]; + // Timestamp of the last new event received via polling. Used to detect + // stale local sessions (desktop stopped syncing). + lastEventAt?: number; } interface TaskSessionStore { @@ -564,6 +571,9 @@ export const useTaskSessionStore = create((set, get) => ({ }); if (shouldPing && usePreferencesStore.getState().pingsEnabled) { playMeepSound().catch(() => {}); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success, + ); } } } catch (statusErr) { @@ -750,6 +760,8 @@ export const useTaskSessionStore = create((set, get) => ({ isPromptPending: nextIsPromptPending, awaitingPing: nextAwaitingPing, awaitingAgentOutput: nextAwaitingAgentOutput, + lastEventAt: + batchedEvents.length > 0 ? Date.now() : current.lastEventAt, }, }, }; @@ -759,6 +771,7 @@ export const useTaskSessionStore = create((set, get) => ({ usePreferencesStore.getState().pingsEnabled ) { playMeepSound().catch(() => {}); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } } } catch (err) { From 0bcf56f129991c80b8adffe0423cdbe81f259f81 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 16:14:43 +0100 Subject: [PATCH 32/39] WIP - Add stoppable tasks annddddd some retries imporvoement --- apps/mobile/src/app/task/[id].tsx | 13 ++++++++ .../src/features/chat/components/Composer.tsx | 27 +++++++++++++--- .../tasks/components/TaskSessionView.tsx | 29 +++++++++++++++-- .../features/tasks/stores/taskSessionStore.ts | 31 +++++++++++++++++++ 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 360865d11..a61c02b14 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -36,6 +36,7 @@ export default function TaskDetailScreen() { connectToTask, disconnectFromTask, sendPrompt, + cancelPrompt, sendPermissionResponse, getSessionForTask, } = useTaskSessionStore(); @@ -130,6 +131,17 @@ export default function TaskDetailScreen() { [taskId, sendPrompt], ); + const handleStop = useCallback(() => { + if (!taskId) return; + cancelPrompt(taskId).catch((err) => { + console.error("Failed to stop agent:", err); + Alert.alert( + "Failed to stop", + "Could not stop the agent. Please try again.", + ); + }); + }, [taskId, cancelPrompt]); + const handleRetry = useCallback(async () => { if (!taskId || !task) return; try { @@ -396,6 +408,7 @@ export default function TaskDetailScreen() { > void; + onStop?: () => void; disabled?: boolean; placeholder?: string; isUserTurn?: boolean; @@ -76,6 +79,7 @@ function PulsingBorder({ active, color }: { active: boolean; color: string }) { export function Composer({ onSend, + onStop, disabled = false, placeholder = "Ask a question", isUserTurn = false, @@ -93,6 +97,7 @@ export function Composer({ if (!trimmed || disabled) return; onSend(trimmed); setMessage(""); + Keyboard.dismiss(); }; const handleMicPress = async () => { @@ -113,7 +118,19 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; - const effectivePlaceholder = placeholder; + const showStop = + !isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop; + + const handleStop = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + onStop?.(); + }; + const effectivePlaceholder = + queuedCount > 0 + ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` + : !isUserTurn && !disabled + ? "Message will be queued..." + : placeholder; if (Platform.OS === "ios") { return ( @@ -182,9 +199,11 @@ export function Composer({
- {/* Mic / Send button */} + {/* Mic / Send / Stop button */} ) : canSend ? ( - ) : isRecording ? ( + ) : isRecording || showStop ? ( { if (!state.pendingAgentText) return; const msg: ParsedMessage = { @@ -266,8 +268,6 @@ function processNewEvents( flushAgentText(); }; - let hasItemMutation = false; - for (let i = state.processedIdx; i < events.length; i++) { const event = events[i]; if (event.type !== "session_update") continue; @@ -684,6 +684,31 @@ export function TaskSessionView({ () => processNewEvents(processorRef.current, events), [events], ); + + // When the agent stops (cancel, completion, terminal), sweep any + // tools still stuck in pending/running to "completed" so their + // spinners stop. + const agentActive = isConnecting || isThinking; + const prevAgentActive = useRef(agentActive); + if (prevAgentActive.current && !agentActive) { + const state = processorRef.current; + let swept = false; + for (const msg of state.toolMessages.values()) { + if ( + msg.toolData && + (msg.toolData.status === "pending" || msg.toolData.status === "running") + ) { + msg.toolData.status = "completed"; + swept = true; + } + } + if (swept) { + state.lastSnapshot = [...state.messages]; + state.lastSnapshotLength = state.messages.length; + } + } + prevAgentActive.current = agentActive; + // Inverted FlatList renders data[0] at the visual bottom. // Reverse so newest messages are at index 0 = bottom. const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index ccddd9438..c3a967b5f 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -342,6 +342,37 @@ export const useTaskSessionStore = create((set, get) => ({ runId: session.taskRunId, }); } catch (err) { + // Transient server errors (504 gateway timeout, etc.) — the sandbox + // may still be alive, just temporarily unreachable. Roll back so the + // user can retry but don't attempt a full resume. + if ( + err instanceof CloudCommandError && + (err.status === 504 || err.status === 502 || err.status === 503) + ) { + logger.warn("Transient server error sending prompt, rolling back", { + status: err.status, + taskId, + }); + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw err; + } + // Sandbox for this run has shut down — create a resume run on the // backend and swap the local session to the new run id. let rollbackError: unknown = err; From e44934fdf07f516fb0b7991126c74a6d4cb0913d Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 16:29:08 +0100 Subject: [PATCH 33/39] fix: REmove queued and fix the task filtering --- apps/mobile/src/app/task/[id].tsx | 1 - apps/mobile/src/features/chat/components/Composer.tsx | 7 +------ apps/mobile/src/features/tasks/hooks/useTasks.ts | 3 ++- apps/mobile/src/features/tasks/stores/taskSessionStore.ts | 3 --- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index a61c02b14..413da0fe3 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -410,7 +410,6 @@ export default function TaskDetailScreen() { onSend={handleSendPrompt} onStop={handleStop} isUserTurn={!(session?.isPromptPending ?? true)} - queuedCount={session?.messageQueue?.length ?? 0} placeholder={ isLocal ? "Reply to continue in cloud" : "Ask a question" } diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index c58a51ca2..1c596107f 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -125,12 +125,7 @@ export function Composer({ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); onStop?.(); }; - const effectivePlaceholder = - queuedCount > 0 - ? `${queuedCount} message${queuedCount > 1 ? "s" : ""} queued...` - : !isUserTurn && !disabled - ? "Message will be queued..." - : placeholder; + const effectivePlaceholder = placeholder; if (Platform.OS === "ios") { return ( diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 63727f1d8..bf87431ed 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -14,7 +14,7 @@ import type { CreateTaskOptions, Task } from "../types"; export const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { repository?: string }) => + list: (filters?: { repository?: string; createdBy?: number }) => [...taskKeys.lists(), filters] as const, details: () => [...taskKeys.all, "detail"] as const, detail: (id: string) => [...taskKeys.details(), id] as const, @@ -27,6 +27,7 @@ export function useTasks(filters?: { repository?: string }) { const queryFilters = { ...filters, + createdBy: currentUser?.id, }; const query = useQuery({ diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index c3a967b5f..bd29cb3a8 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -86,9 +86,6 @@ export interface TaskSession { // True after a user prompt is sent, cleared when the first piece of // agent output (tool call, message, etc.) arrives from polling. awaitingAgentOutput?: boolean; - // Messages queued while the agent is working. Auto-sent when control - // returns (isPromptPending flips to false). - messageQueue?: string[]; // Timestamp of the last new event received via polling. Used to detect // stale local sessions (desktop stopped syncing). lastEventAt?: number; From 6f550198b9db4576a126c8d68dff223b144ac181 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 16:46:14 +0100 Subject: [PATCH 34/39] wip: Small UX work to wipe button won msg send and error handling improvmeentS --- apps/mobile/src/app/task/[id].tsx | 10 +++------- apps/mobile/src/features/chat/components/Composer.tsx | 2 +- .../src/features/tasks/stores/taskSessionStore.ts | 3 +++ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 413da0fe3..581538d2b 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -133,13 +133,9 @@ export default function TaskDetailScreen() { const handleStop = useCallback(() => { if (!taskId) return; - cancelPrompt(taskId).catch((err) => { - console.error("Failed to stop agent:", err); - Alert.alert( - "Failed to stop", - "Could not stop the agent. Please try again.", - ); - }); + // cancelPrompt returns false on failure — no need to alert, + // the agent may have already finished or the sandbox expired. + cancelPrompt(taskId).catch(() => {}); }, [taskId, cancelPrompt]); const handleRetry = useCallback(async () => { diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index 1c596107f..6d12d35bf 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -95,9 +95,9 @@ export function Composer({ const handleSend = () => { const trimmed = message.trim(); if (!trimmed || disabled) return; - onSend(trimmed); setMessage(""); Keyboard.dismiss(); + onSend(trimmed); }; const handleMicPress = async () => { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index bd29cb3a8..70117dcaf 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -66,6 +66,7 @@ const CLOUD_POLLING_INTERVAL_MS = 500; export interface TaskSession { taskRunId: string; taskId: string; + taskTitle?: string; events: SessionEvent[]; status: "connecting" | "connected" | "disconnected" | "error"; isPromptPending: boolean; @@ -174,6 +175,7 @@ export const useTaskSessionStore = create((set, get) => ({ [newRunId]: { taskRunId: newRunId, taskId, + taskTitle: task.title, events: [], status: "connected", isPromptPending: true, @@ -232,6 +234,7 @@ export const useTaskSessionStore = create((set, get) => ({ [latestRunId]: { taskRunId: latestRunId, taskId, + taskTitle: task.title, events: historicalEvents, status: "connected", isPromptPending, From 389bc1fccda225764b3bcb1945303130c7a31c8b Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 16:59:05 +0100 Subject: [PATCH 35/39] wip: Add cloud polling when the app regains focus --- .../features/tasks/stores/taskSessionStore.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 70117dcaf..299d7303b 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,4 +1,5 @@ import * as Haptics from "expo-haptics"; +import { AppState } from "react-native"; import { create } from "zustand"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; @@ -66,7 +67,6 @@ const CLOUD_POLLING_INTERVAL_MS = 500; export interface TaskSession { taskRunId: string; taskId: string; - taskTitle?: string; events: SessionEvent[]; status: "connecting" | "connected" | "disconnected" | "error"; isPromptPending: boolean; @@ -175,7 +175,6 @@ export const useTaskSessionStore = create((set, get) => ({ [newRunId]: { taskRunId: newRunId, taskId, - taskTitle: task.title, events: [], status: "connected", isPromptPending: true, @@ -234,7 +233,6 @@ export const useTaskSessionStore = create((set, get) => ({ [latestRunId]: { taskRunId: latestRunId, taskId, - taskTitle: task.title, events: historicalEvents, status: "connected", isPromptPending, @@ -886,3 +884,25 @@ export const useTaskSessionStore = create((set, get) => ({ }); }, })); + +// When the app returns from background, iOS resumes JS execution but +// in-flight fetches may have been killed. Clear the pollInFlight guards +// and restart polling for all active sessions to catch up immediately. +AppState.addEventListener("change", (nextState) => { + if (nextState === "active") { + pollInFlight.clear(); + pollInFlightSince.clear(); + for (const [taskRunId, interval] of cloudPollers) { + clearInterval(interval); + cloudPollers.delete(taskRunId); + } + const sessions = useTaskSessionStore.getState().sessions; + for (const session of Object.values(sessions)) { + if (session.status === "connected" && !session.terminalStatus) { + useTaskSessionStore + .getState() + ._startCloudPolling(session.taskRunId, session.logUrl); + } + } + } +}); From 591172c182e83de564e512538c2ed87e0b1151cc Mon Sep 17 00:00:00 2001 From: Sandy Spicer Date: Thu, 16 Apr 2026 17:08:23 +0100 Subject: [PATCH 36/39] camera --- apps/mobile/app.json | 10 +- apps/mobile/package.json | 1 + apps/mobile/src/app/auth.tsx | 39 ++++++ apps/mobile/src/components/QrScanModal.tsx | 134 +++++++++++++++++++++ pnpm-lock.yaml | 55 +++++++++ 5 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/components/QrScanModal.tsx diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 78cd9d092..6bf92e392 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -21,6 +21,7 @@ "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", "NSPhotoLibraryUsageDescription": "Allow PostHog to access your photos for attaching images to tasks", + "NSCameraUsageDescription": "Allow PostHog to use your camera to scan QR codes for sign-in", "ITSAppUsesNonExemptEncryption": false, "LSApplicationQueriesSchemes": ["posthog-code"] } @@ -35,7 +36,8 @@ "package": "com.posthog.code.mobile", "permissions": [ "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS" + "android.permission.MODIFY_AUDIO_SETTINGS", + "android.permission.CAMERA" ] }, "web": { @@ -49,6 +51,12 @@ "microphonePermission": "Allow PostHog to use your microphone for voice-to-text input" } ], + [ + "expo-camera", + { + "cameraPermission": "Allow PostHog to use your camera to scan QR codes for sign-in" + } + ], [ "expo-font", { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2475feace..5484f36f3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -31,6 +31,7 @@ "expo-application": "~7.0.8", "expo-auth-session": "^7.0.10", "expo-av": "~16.0.8", + "expo-camera": "^55.0.15", "expo-clipboard": "^55.0.13", "expo-constants": "~18.0.11", "expo-crypto": "^15.0.8", diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index e0678cfdf..0992d2634 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -9,6 +9,7 @@ import { View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { QrScanModal, type QrScanResult } from "@/components/QrScanModal"; import { type CloudRegion, useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; @@ -37,9 +38,30 @@ export default function AuthScreen() { const [error, setError] = useState(null); const [devToken, setDevToken] = useState(""); const [devProjectId, setDevProjectId] = useState(""); + const [scannerVisible, setScannerVisible] = useState(false); const { loginWithOAuth, loginWithPersonalApiKey } = useAuthStore(); + const handleQrScan = async (result: QrScanResult) => { + setScannerVisible(false); + setDevToken(result.apiKey); + setDevProjectId(String(result.projectId)); + setIsLoading(true); + setError(null); + try { + await loginWithPersonalApiKey({ + token: result.apiKey, + projectId: result.projectId, + region: selectedRegion, + }); + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to sign in"); + } finally { + setIsLoading(false); + } + }; + const handleDevSignIn = async () => { setIsLoading(true); setError(null); @@ -207,10 +229,27 @@ export default function AuthScreen() { Dev sign in
+ { + setError(null); + setScannerVisible(true); + }} + disabled={isLoading} + > + + Scan QR code + +
)}
+ setScannerVisible(false)} + onScan={handleQrScan} + /> ); } diff --git a/apps/mobile/src/components/QrScanModal.tsx b/apps/mobile/src/components/QrScanModal.tsx new file mode 100644 index 000000000..b9cdc9c8a --- /dev/null +++ b/apps/mobile/src/components/QrScanModal.tsx @@ -0,0 +1,134 @@ +import { CameraView, useCameraPermissions } from "expo-camera"; +import { useCallback, useRef, useState } from "react"; +import { + ActivityIndicator, + Modal, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export type QrScanResult = { + apiKey: string; + projectId: number; +}; + +type QrScanModalProps = { + visible: boolean; + onClose: () => void; + onScan: (result: QrScanResult) => void; +}; + +function parseQrPayload(raw: string): QrScanResult | null { + try { + const parsed = JSON.parse(raw); + const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : null; + const projectIdRaw = parsed.projectId; + const projectId = + typeof projectIdRaw === "number" + ? projectIdRaw + : typeof projectIdRaw === "string" + ? Number(projectIdRaw) + : NaN; + if (!apiKey || !Number.isFinite(projectId) || projectId <= 0) { + return null; + } + return { apiKey, projectId }; + } catch { + return null; + } +} + +export function QrScanModal({ visible, onClose, onScan }: QrScanModalProps) { + const [permission, requestPermission] = useCameraPermissions(); + const [error, setError] = useState(null); + const hasScannedRef = useRef(false); + + const handleBarcode = useCallback( + (event: { data: string }) => { + if (hasScannedRef.current) { + return; + } + const parsed = parseQrPayload(event.data); + if (!parsed) { + setError("QR code is not a valid PostHog sign-in code"); + return; + } + hasScannedRef.current = true; + onScan(parsed); + }, + [onScan], + ); + + const handleClose = useCallback(() => { + hasScannedRef.current = false; + setError(null); + onClose(); + }, [onClose]); + + return ( + { + hasScannedRef.current = false; + setError(null); + }} + > + + + + Scan sign-in QR + + + Close + + + + + {!permission ? ( + + + + ) : !permission.granted ? ( + + + PostHog needs camera access to scan the QR code shown on the web + app. + + + + Grant camera access + + + + ) : ( + + + {error ? ( + + {error} + + ) : null} + + Point your camera at the QR code shown in the PostHog web app + after creating a personal API key. + + + + )} + + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b814478c..fe9112848 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -533,6 +533,9 @@ importers: expo-av: specifier: ~16.0.8 version: 16.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-camera: + specifier: ^55.0.15 + version: 55.0.15(@types/emscripten@1.41.5)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-clipboard: specifier: ^55.0.13 version: 55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) @@ -5163,6 +5166,9 @@ packages: '@types/earcut@3.0.0': resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -5762,6 +5768,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + barcode-detector@3.1.2: + resolution: {integrity: sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==} + base32-encode@1.2.0: resolution: {integrity: sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==} @@ -6891,6 +6900,17 @@ packages: react-native-web: optional: true + expo-camera@55.0.15: + resolution: {integrity: sha512-WRVsZf+2p7EsxudwyiUMYijJS8M98t/BVP6yG7N+08JSUotkGjmZcemom1gM36uy27P8QsSVP0hD+FravmQiBA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + expo-clipboard@55.0.13: resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==} peerDependencies: @@ -11079,6 +11099,10 @@ packages: resolution: {integrity: sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA==} engines: {node: '>=20'} + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -11766,6 +11790,11 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + zxing-wasm@3.0.2: + resolution: {integrity: sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + snapshots: '@0no-co/graphql.web@1.2.0(graphql@16.12.0)': @@ -16827,6 +16856,8 @@ snapshots: '@types/earcut@3.0.0': {} + '@types/emscripten@1.41.5': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -17554,6 +17585,12 @@ snapshots: balanced-match@4.0.4: {} + barcode-detector@3.1.2(@types/emscripten@1.41.5): + dependencies: + zxing-wasm: 3.0.2(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + base32-encode@1.2.0: dependencies: to-data-view: 1.1.0 @@ -18687,6 +18724,15 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-camera@55.0.15(@types/emscripten@1.41.5)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + barcode-detector: 3.1.2(@types/emscripten@1.41.5) + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + transitivePeerDependencies: + - '@types/emscripten' + expo-clipboard@55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): dependencies: expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) @@ -23636,6 +23682,10 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-fest@5.5.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -24319,3 +24369,8 @@ snapshots: react: 19.1.0 zwitch@2.0.4: {} + + zxing-wasm@3.0.2(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.5.0 From 47a94ce84a2779daf53c87eb4cb7616e9a54d886 Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 17:09:31 +0100 Subject: [PATCH 37/39] wip: improvmenets to the show where to run feature --- apps/mobile/src/app/task/[id].tsx | 67 +++++++++++++------------------ 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 581538d2b..d6dd80de1 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -3,6 +3,7 @@ import * as Haptics from "expo-haptics"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { + ActionSheetIOS, ActivityIndicator, Alert, Linking, @@ -72,17 +73,16 @@ export default function TaskDetailScreen() { setTask(fetchedTask); return connectToTask(fetchedTask); }) - .then(() => { - if (cancelled) return; - // Brief delay for FlatList to render its initial batch behind - // the loading overlay before revealing. - setTimeout(() => setLoading(false), 150); - }) .catch((err) => { if (cancelled) return; console.error("Failed to load task:", err); setError("Failed to load task"); - setLoading(false); + }) + .finally(() => { + if (cancelled) return; + // Brief delay for FlatList to render its initial batch behind + // the loading overlay before revealing. + setTimeout(() => setLoading(false), 150); }); return () => { @@ -315,6 +315,28 @@ export default function TaskDetailScreen() { headerRight: environment ? () => ( + ActionSheetIOS.showActionSheetWithOptions( + { + options: [ + "Cancel", + "Open on Desktop", + "Continue in Cloud", + ], + cancelButtonIndex: 0, + title: isStale + ? "Desktop may be offline" + : "Running on your desktop", + }, + (index) => { + if (index === 1) handleOpenOnDesktop(); + if (index === 2) handleContinueInCloud(); + }, + ) + : undefined + } className={`rounded-full px-3 py-1 ${ environment === "cloud" ? "bg-accent-3" : "bg-gray-4" }`} @@ -365,37 +387,6 @@ export default function TaskDetailScreen() {
)} - {/* Local task banner */} - {isLocal && !session?.terminalStatus && !loading && ( - - - - {isStale ? "Desktop may be offline" : "Running on your desktop"} - - - - - Open on Desktop - - - - - Continue in Cloud - - - - - - )} - {/* Fixed input at bottom — hidden when run is terminal */} {!session?.terminalStatus && ( Date: Thu, 16 Apr 2026 17:43:48 +0100 Subject: [PATCH 38/39] wip: Handle continue in the cloud thing better --- apps/mobile/src/app/task/[id].tsx | 48 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index d6dd80de1..fead2cabb 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -6,7 +7,6 @@ import { ActionSheetIOS, ActivityIndicator, Alert, - Linking, Pressable, View, } from "react-native"; @@ -19,6 +19,7 @@ import { runTaskInCloud, type Task, TaskSessionView, + taskKeys, useTaskSessionStore, } from "@/features/tasks"; import { useThemeColors } from "@/lib/theme"; @@ -26,6 +27,7 @@ import { useThemeColors } from "@/lib/theme"; export default function TaskDetailScreen() { const { id: taskId } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); + const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const themeColors = useThemeColors(); const [task, setTask] = useState(null); @@ -138,6 +140,18 @@ export default function TaskDetailScreen() { cancelPrompt(taskId).catch(() => {}); }, [taskId, cancelPrompt]); + const updateTaskInCache = useCallback( + (updated: Task) => { + // Directly patch the task in all list query caches so the task list + // reflects the change immediately (e.g., environment: local → cloud). + queryClient.setQueriesData( + { queryKey: taskKeys.lists() }, + (old) => old?.map((t) => (t.id === updated.id ? updated : t)), + ); + }, + [queryClient], + ); + const handleRetry = useCallback(async () => { if (!taskId || !task) return; try { @@ -149,6 +163,7 @@ export default function TaskDetailScreen() { }); setTask(updatedTask); await connectToTask(updatedTask); + updateTaskInCache(updatedTask); // Don't clear retrying here — the effect below clears it // once the session shows meaningful state (thinking or terminal). } catch (err) { @@ -159,7 +174,7 @@ export default function TaskDetailScreen() { "Could not restart the task. Please try again.", ); } - }, [taskId, task, disconnectFromTask, connectToTask]); + }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); // Clear retrying once the agent finishes a turn or the run terminates. useEffect(() => { @@ -206,19 +221,6 @@ export default function TaskDetailScreen() { return () => clearInterval(interval); }, [isLocal, session?.isPromptPending, session?.lastEventAt]); - const handleOpenOnDesktop = useCallback(async () => { - if (!taskId) return; - const url = `posthog-code://task/${taskId}`; - try { - await Linking.openURL(url); - } catch { - Alert.alert( - "Desktop app not found", - "Install PostHog Code on your Mac to open tasks locally.", - ); - } - }, [taskId]); - const handleContinueInCloud = useCallback(async () => { if (!taskId || !task) return; try { @@ -229,6 +231,7 @@ export default function TaskDetailScreen() { }); setTask(updatedTask); await connectToTask(updatedTask); + updateTaskInCache(updatedTask); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (err) { console.error("Failed to continue in cloud:", err); @@ -238,7 +241,7 @@ export default function TaskDetailScreen() { "Could not continue this task in the cloud. Please try again.", ); } - }, [taskId, task, disconnectFromTask, connectToTask]); + }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); const environment = task?.latest_run?.environment; @@ -320,19 +323,14 @@ export default function TaskDetailScreen() { ? () => ActionSheetIOS.showActionSheetWithOptions( { - options: [ - "Cancel", - "Open on Desktop", - "Continue in Cloud", - ], + options: ["Keep locally", "Move to Cloud"], cancelButtonIndex: 0, title: isStale ? "Desktop may be offline" : "Running on your desktop", }, (index) => { - if (index === 1) handleOpenOnDesktop(); - if (index === 2) handleContinueInCloud(); + if (index === 1) handleContinueInCloud(); }, ) : undefined @@ -397,9 +395,7 @@ export default function TaskDetailScreen() { onSend={handleSendPrompt} onStop={handleStop} isUserTurn={!(session?.isPromptPending ?? true)} - placeholder={ - isLocal ? "Reply to continue in cloud" : "Ask a question" - } + placeholder={"Ask a question"} /> )} From d299931329b71f63fc350a4eecce0cf4fe1c8b9c Mon Sep 17 00:00:00 2001 From: Vasco de Krijger Date: Thu, 16 Apr 2026 17:49:06 +0100 Subject: [PATCH 39/39] feat: fix identifier --- apps/mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 6bf92e392..6014e79f3 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -16,7 +16,7 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.posthog.code", + "bundleIdentifier": "com.posthog.code.mobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device",