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/build/dmg-background.png b/apps/code/build/dmg-background.png new file mode 100644 index 000000000..895453103 Binary files /dev/null and b/apps/code/build/dmg-background.png differ 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": { 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..b765e5c43 --- /dev/null +++ b/apps/code/src/main/services/agent/local-command-receiver.ts @@ -0,0 +1,288 @@ +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 + * 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 { + // 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; + // Set after we drop a stale Last-Event-ID so the next fetch asks for + // start=latest — we accept dropping commands issued during the outage + // rather than looping forever on an un-resumable cursor. + let droppedStaleCursor = false; + + 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 (droppedStaleCursor) { + 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; + droppedStaleCursor = false; + lastEventId = await this.readEventStream( + response.body, + params.onCommand, + controller.signal, + lastEventId, + params.taskRunId, + ); + 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; + droppedStaleCursor = true; + } + 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 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(); + 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; + this.saveCursor(taskRunId, 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..0a853cbbf 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,129 @@ 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("keeps the subscription alive after session cleanup so mobile can wake a new session", async () => { + await service.startSession({ + ...baseSessionParams, + runMode: "local", + }); + + await ( + service as unknown as { + cleanupSession: (id: string) => Promise; + } + ).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 d2fb6eed3..dda756931 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -51,6 +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 { + IncomingCommandPayload, + LocalCommandReceiver, +} from "./local-command-receiver"; import { AgentServiceEvent, type AgentServiceEvents, @@ -257,6 +261,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 { @@ -313,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, @@ -324,6 +339,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 +354,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 +364,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 +409,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -418,6 +438,7 @@ export class AgentService extends TypedEventEmitter { }); this.pendingPermissions.delete(key); + this.emit(AgentServiceEvent.PermissionResolved, { taskRunId, toolCallId }); this.recordActivity(taskRunId); } @@ -549,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, @@ -569,295 +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 (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 (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 && isAuthError(err)) { - log.warn( - `Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`, - { 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 }, + ); + 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; } } @@ -1161,7 +1238,159 @@ For git operations while detached: session.inFlightMcpToolCalls.clear(); } + /** + * 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. + */ + 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: 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, + 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, + 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; + } + + // 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, + hasSessionId: !!config.sessionId, + }); + const session = await this.getOrCreateSession(config, true); + if (!session) { + log.error("Lazy-spawn failed; dropping mobile command", { + taskRunId, + }); + return; + } + } + 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 { + // 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) { this.cancelInFlightMcpToolCalls(session); @@ -1484,6 +1713,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/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/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 98c20a8ce..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 }) => @@ -105,6 +120,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/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/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/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/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/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/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/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/sessions/hooks/useBackgroundSubscriptions.ts b/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts new file mode 100644 index 000000000..48541f1b3 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useBackgroundSubscriptions.ts @@ -0,0 +1,112 @@ +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"; +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); + + // 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)) { + 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/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/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index a47d7ee56..46c210c30 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) }, })); @@ -296,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(), }); @@ -1024,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"); }); }); @@ -1363,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 75d319dd3..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,12 +150,15 @@ 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, { event: { unsubscribe: () => void }; permission?: { unsubscribe: () => void }; + permissionResolved?: { unsubscribe: () => void }; } >(); /** Active cloud task watchers, keyed by taskId */ @@ -185,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 }); @@ -377,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); @@ -442,6 +456,7 @@ export class SessionService { adapter: resolvedAdapter, permissionMode: persistedMode, customInstructions: customInstructions || undefined, + runMode: "local", }); if (result) { @@ -493,6 +508,7 @@ export class SessionService { ), ); } + return true; } else { log.warn("Reconnect returned null", { taskId, taskRunId }); this.setErrorSession( @@ -501,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 = @@ -513,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) { @@ -528,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); } @@ -579,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, @@ -616,6 +767,7 @@ export class SessionService { ? (reasoningLevel as EffortLevel) : undefined, model: preferredModel, + runMode: "local", }); const session = this.createBaseSession(taskRun.id, taskId, taskTitle); @@ -700,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.", + ); }, }, ); @@ -725,9 +889,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 +922,7 @@ export class SessionService { const subscription = this.subscriptions.get(taskRunId); subscription?.event.unsubscribe(); subscription?.permission?.unsubscribe(); + subscription?.permissionResolved?.unsubscribe(); this.subscriptions.delete(taskRunId); } @@ -760,6 +948,8 @@ export class SessionService { } this.connectingTasks.clear(); + this.localRepoPaths.clear(); + this.localRecoveryAttempts.clear(); this.cloudPermissionRequestIds.clear(); this.idleKilledSubscription?.unsubscribe(); this.idleKilledSubscription = null; @@ -1175,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, @@ -1947,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; @@ -1975,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); } @@ -1992,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; @@ -2020,7 +2211,7 @@ export class SessionService { ? undefined : (overrideSessionId ?? prefetchedLogs.sessionId); - await this.reconnectToLocalSession( + return this.reconnectToLocalSession( taskId, taskRunId, taskTitle, @@ -2607,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, 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/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/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/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/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) { 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/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(); + }; +} 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); +} 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}]`); } } } 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; +} diff --git a/apps/mobile/app.json b/apps/mobile/app.json index cb58a4bfc..6014e79f3 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,10 +16,14 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.posthog.mobile", + "bundleIdentifier": "com.posthog.code.mobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", - "ITSAppUsesNonExemptEncryption": false + "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"] } }, "android": { @@ -29,10 +33,11 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.posthog.mobile", + "package": "com.posthog.code.mobile", "permissions": [ "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS" + "android.permission.MODIFY_AUDIO_SETTINGS", + "android.permission.CAMERA" ] }, "web": { @@ -46,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", { @@ -152,7 +163,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 000000000..fd7b4cf7e Binary files /dev/null and b/apps/mobile/assets/sounds/meep.mp3 differ 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..5484f36f3 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,15 @@ "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-camera": "^55.0.15", + "expo-clipboard": "^55.0.13", "expo-constants": "~18.0.11", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -37,15 +40,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 */} { + // 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 = useCallback(() => { + if (!readyRef.current) return; + readyRef.current = false; router.push("/task"); - }; + }, [router]); - 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 +45,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..0992d2634 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -1,7 +1,15 @@ 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 { QrScanModal, type QrScanResult } from "@/components/QrScanModal"; import { type CloudRegion, useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; @@ -28,8 +36,52 @@ 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 [scannerVisible, setScannerVisible] = useState(false); - const { loginWithOAuth } = useAuthStore(); + 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); + 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 +109,11 @@ export default function AuthScreen() { return ( - + {/* Header */} @@ -131,8 +187,69 @@ 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 + + + { + setError(null); + setScannerVisible(true); + }} + disabled={isLoading} + > + + Scan QR code + + + + )} - + + setScannerVisible(false)} + onScan={handleQrScan} + /> ); } diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 35a1aa2d7..fead2cabb 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,15 +1,25 @@ 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, useState } from "react"; -import { ActivityIndicator, Pressable, View } from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActionSheetIOS, + 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, + taskKeys, useTaskSessionStore, } from "@/features/tasks"; import { useThemeColors } from "@/lib/theme"; @@ -17,14 +27,22 @@ 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); 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, + cancelPrompt, + sendPermissionResponse, + getSessionForTask, + } = useTaskSessionStore(); const session = taskId ? getSessionForTask(taskId) : undefined; @@ -47,66 +65,216 @@ 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); }) .catch((err) => { + if (cancelled) return; console.error("Failed to load task:", err); setError("Failed to load task"); }) .finally(() => { - setLoading(false); + if (cancelled) return; + // Brief delay for FlatList to render its initial batch behind + // the loading overlay before revealing. + setTimeout(() => setLoading(false), 150); }); 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; + 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 handleStop = useCallback(() => { + if (!taskId) return; + // 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 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 { + setRetrying(true); + disconnectFromTask(taskId); + + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + }); + 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) { + 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, updateTaskInCache]); + + // 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... - - - ); - } + // 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 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); + updateTaskInCache(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, updateTaskInCache]); + + const environment = task?.latest_run?.environment; - if (error || !task) { + 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 ( <> ( + + ActionSheetIOS.showActionSheetWithOptions( + { + options: ["Keep locally", "Move to Cloud"], + cancelButtonIndex: 0, + title: isStale + ? "Desktop may be offline" + : "Running on your desktop", + }, + (index) => { + if (index === 1) handleContinueInCloud(); + }, + ) + : undefined + } + className={`rounded-full px-3 py-1 ${ + environment === "cloud" ? "bg-accent-3" : "bg-gray-4" + }`} + > + + {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..3cf60940a 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,11 +1,14 @@ 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, + Keyboard, + KeyboardAvoidingView, + Platform, Pressable, + ScrollView, TextInput, View, } from "react-native"; @@ -79,10 +82,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 +126,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}`); @@ -148,78 +165,113 @@ export default function NewTaskScreen() { presentation: "modal", }} /> - - {loadingRepos ? ( - - - - Loading repositories... - - - ) : !hasGithubIntegration ? ( - - ) : ( - <> - Repository - - item} - renderItem={({ item }) => ( - setSelectedRepo(item)} - className={`border-gray-6 border-b px-3 py-3 ${ - selectedRepo === item ? "bg-accent-3" : "" - }`} - > + + + + {loadingRepos ? ( + + + + Loading repositories... + + + ) : !hasGithubIntegration ? ( + + ) : ( + <> + Repository + + + {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} + + + )) + )} + + + + Task description + + + + + {creating ? ( + + ) : ( - {item} + Create task - - )} - /> - - - Task description - - - - {creating ? ( - - ) : ( - - Create task - - )} - - - )} - + )} + + + )} + + + ); } 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/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/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..6d12d35bf 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -1,8 +1,12 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; +import * as Haptics from "expo-haptics"; import { ArrowUp, Microphone, Stop } from "phosphor-react-native"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, + Animated, + Easing, + Keyboard, Platform, TextInput, TouchableOpacity, @@ -13,14 +17,72 @@ import { useVoiceRecording } from "../hooks/useVoiceRecording"; interface ComposerProps { onSend: (message: string) => void; + onStop?: () => 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, + onStop, disabled = false, placeholder = "Ask a question", + isUserTurn = false, }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); @@ -33,8 +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 () => { @@ -55,6 +118,14 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; + const showStop = + !isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop; + + const handleStop = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + onStop?.(); + }; + const effectivePlaceholder = placeholder; if (Platform.OS === "ios") { return ( @@ -85,44 +156,49 @@ export function Composer({ gap: 8, }} > - {/* Input field with rounded glass background */} - - + + - + isInteractive + > + + + - {/* Mic / Send button */} + {/* Mic / Send / Stop button */} ) : canSend ? ( - ) : isRecording ? ( + ) : isRecording || showStop ? ( { + Clipboard.setStringAsync(content).then(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Copied", "Message copied to clipboard."); + }); + }, [content]); + return ( - - - {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..0ce7301be --- /dev/null +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -0,0 +1,432 @@ +import { useMemo } from "react"; +import { Linking, 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" + | "blockquote" + | "hr"; + 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; + } + + // 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[] = []; + 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]) && + !/^>\s?/.test(lines[i]) && + !/^([-*_])\s*\1\s*\1[\s1]*$/.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 openUrl(url: string) { + Linking.openURL(url); +} + +function renderInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + // Links must come first to avoid bold/italic consuming text inside [] + 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] && match[3]) { + // Link: [text](url) + const url = match[3]; + nodes.push( + openUrl(url)} + > + {match[2]} + , + ); + } else if (match[4]) { + // Bold + nodes.push( + + {match[4]} + , + ); + } else if (match[5]) { + // Italic + nodes.push( + + {match[5]} + , + ); + } else if (match[6]) { + // Inline code + nodes.push( + + {match[6]} + , + ); + } + + 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)} + + + ); + })} + + ); + })} + + + ); + } + + case "blockquote": + return ( + + + {renderInline(block.content)} + + + ); + + case "hr": + return ; + + 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..0b129f109 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -0,0 +1,142 @@ +import * as Haptics from "expo-haptics"; +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; + }, [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; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + 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..188714b6e 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,49 @@ 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 }; + } + + let hasItemMutation = false; 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 = ""; }; - for (const event of events) { + const flushPending = () => { + flushThoughtText(); + flushAgentText(); + }; + + 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,605 @@ 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], + ); + + // 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]); 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..bf87431ed 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -64,23 +64,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..299d7303b 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,6 +1,16 @@ +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"; -import { appendTaskRunLog, fetchS3Logs, runTaskInCloud } from "../api"; +import { + CloudCommandError, + fetchS3Logs, + getTask, + getTaskRun, + runTaskInCloud, + sendCloudCommand, +} from "../api"; import type { SessionEvent, SessionNotification, @@ -11,6 +21,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 +73,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; + // Timestamp of the last new event received via polling. Used to detect + // stale local sessions (desktop stopped syncing). + lastEventAt?: number; } interface TaskSessionStore { @@ -31,16 +98,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 +142,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 +175,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 +203,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 +238,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 +293,209 @@ 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", + // 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 + // 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, + 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) { + // 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; + 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 +542,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 +560,58 @@ 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, + }, + }, + }; + }); + if (shouldPing && usePreferencesStore.getState().pingsEnabled) { + playMeepSound().catch(() => {}); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success, + ); + } + } + } catch (statusErr) { + logger.warn("Failed to fetch task run status", { + error: statusErr, + }); + } + } + const text = await fetchS3Logs(logUrl); if (!text) return; @@ -310,9 +620,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 +642,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 +727,87 @@ 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, + lastEventAt: + batchedEvents.length > 0 ? Date.now() : current.lastEventAt, + }, }, - }, - })); + }; + }); + if ( + shouldPingAfterBatch && + usePreferencesStore.getState().pingsEnabled + ) { + playMeepSound().catch(() => {}); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } } } catch (err) { logger.warn("Cloud polling error", { error: err }); + } finally { + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); } }; @@ -398,7 +821,88 @@ 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, + }); + }, })); + +// 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); + } + } + } +}); 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..fe9112848 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,12 @@ 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) 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 +560,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 +578,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 +593,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 +4391,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'} @@ -5142,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==} @@ -5741,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==} @@ -6870,6 +6900,24 @@ 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: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.13: resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} peerDependencies: @@ -6926,6 +6974,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 +7065,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 +7614,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'} @@ -11035,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'} @@ -11722,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)': @@ -15946,6 +16019,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)': @@ -16778,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 @@ -17505,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 @@ -18638,6 +18724,21 @@ 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) + 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 +18805,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 +18907,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 +19606,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 +21754,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: @@ -23569,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 @@ -24252,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