import * as Option from "effect/Option";import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";import * as Schema from "effect/Schema";import { requestKindFromRequestType, type PendingApproval,} from "@t3tools/client-runtime/pending-requests";import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts";import type { OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, ToolLifecycleItemType, TurnId, UserInputQuestion,} from "@t3tools/contracts";import { formatDuration } from "@t3tools/shared/orchestrationTiming";import { commandDetailRepeatsCommand, extractCommandOutputText, extractWorkLogToolLifecycleStatus, isWorktreeSetupActivity, liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, workEntryIndicatesToolFailure, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, type ToolGroupSummaryKind, type WorkLogToolLifecycleStatus,} from "@t3tools/client-runtime/work-log/presentation";import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array";import * as Order from "effect/Order"; export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests"; export interface PendingUserInputDraftAnswer { readonly selectedOptionValues?: ReadonlyArray<string>; readonly customAnswer?: string; readonly attachmentCount?: number; readonly attachmentsBlocked?: boolean;} export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; readonly canExpand: boolean; readonly getFullDetail: () => string | null; readonly getCopyText: () => string; readonly icon: | "agent" | "alert" | "browser" | "check" | "command" | "computer" | "edit" | "eye" | "globe" | "hammer" | "message" | "warning" | "wrench" | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; readonly lifecycleStatus?: WorkLogToolLifecycleStatus; readonly workEntry: WorkLogEntry; readonly groupedToolDetail?: boolean; readonly live?: boolean;} export interface WorkLogEntry { readonly questionAnswer?: UserInputAttachmentAnswerPayload; id: string; createdAt: string; turnId: TurnId | null; label: string; detail?: string; viewedImagePath?: string; command?: string; rawCommand?: string; changedFiles?: ReadonlyArray<string>; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; toolSurface?: import("@t3tools/contracts").ToolActivitySurface; toolIcon?: import("@t3tools/contracts").ToolActivityIcon; toolSource?: import("@t3tools/contracts").ToolActivitySource; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; sourceActivityKind?: OrchestrationThreadActivity["kind"]; toolCallId?: string; /** * One row per workflow run or per-turn batch of direct spawns, like web's * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row * also carries each agent's terminal state to derive its status label. */ agentSpawn?: { readonly workflowId: string | null; readonly agentTaskIds: ReadonlyArray<string>; readonly agents: ReadonlyArray<{ readonly title: string; readonly status: WorkLogToolLifecycleStatus | undefined; readonly detail: string | undefined; /** When this member last reported, so the card can show the newest activity. */ readonly updatedAt: string; }>; }; toolData?: unknown;} interface DerivedWorkLogEntry extends WorkLogEntry { sourceActivityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; /** The tool call that launched this agent, when the provider reports one. */ agentSpawnToolCallId?: string; isWorkflowCoordinator?: boolean; /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ isBackgroundTask?: boolean;} type RawThreadFeedEntry = | { readonly type: "message"; readonly id: string; readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; } | { readonly type: "activity"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; }; export type ThreadFeedEntry = | Extract<RawThreadFeedEntry, { type: "message" }> | { readonly type: "activity-group"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly activities: ReadonlyArray<ThreadFeedActivity>; } | { readonly type: "work-toggle"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly groupId: string; readonly hiddenCount: number; readonly expanded: boolean; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; readonly toolSurface?: WorkLogEntry["toolSurface"]; readonly toolIcon?: WorkLogEntry["toolIcon"]; readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; } | { readonly type: "turn-fold"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId; readonly label: string; readonly expanded: boolean; } | { /** * The turn's single live slot. Web keys its live tool row and its * "Thinking" row identically so the slot updates in place; here the * slot holds "Thinking" whenever no tool row is shimmering, so a tool * failing does not insert a row under the group it lives in. */ readonly type: "thinking"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; } | { /** * One batch of spawned subagents. Rendered as its own card because a * single-line tool row has no room for what the agents are doing now, * which on a phone is the one thing worth showing. */ readonly type: "agent-spawn"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; readonly expanded: boolean; readonly summary: AgentSpawnSummary; }; export interface AgentSpawnSummary { /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */ readonly title: string; /** Latest member activity while working, else the batch outcome. */ readonly status: string; readonly tone: "working" | "completed" | "failed" | "stopped"; readonly members: ReadonlyArray<{ readonly title: string; readonly status: string; readonly tone: "working" | "completed" | "failed" | "stopped"; readonly detail: string | undefined; readonly updatedAt: string; }>;} export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt">; type ThreadFeedActivityGroup = Extract<ThreadFeedEntry, { readonly type: "activity-group" }>; // These keys are immutable inputs. Weak caches release old histories with their source data.const activityEntriesCache = new WeakMap< ReadonlyArray<OrchestrationThreadActivity>, ReadonlyArray<Extract<RawThreadFeedEntry, { readonly type: "activity" }>>>();const messageEntriesCache = new WeakMap< OrchestrationThread["messages"][number], Extract<RawThreadFeedEntry, { readonly type: "message" }>>();const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>();const presentedActivityGroupsCache = new WeakMap< ThreadFeedActivityGroup, { readonly unsettledTurnId: TurnId | null; readonly isWorking: boolean; readonly activeTail: boolean; readonly rows: ReadonlyArray<ThreadFeedEntry>; }>();const turnFoldRowsCache = new WeakMap< ThreadFeedEntry, Extract<ThreadFeedEntry, { readonly type: "turn-fold" }>>();let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null; export function isContextCompactionActivityGroup( entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,): boolean { return ( entry.activities.length === 1 && entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction" );} function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null;} function resolvePendingUserInputOptionValue( question: UserInputQuestion, value: string,): string | null { if (question.options.some((option) => option.value === value)) { return value; } const label = value.trim(); return label.length > 0 && question.options.some((option) => option.value === undefined && option.label.trim() === label) ? label : null;} function normalizeSelectedOptionValues( question: UserInputQuestion, value: ReadonlyArray<string> | undefined,): ReadonlyArray<string> { if (!Array.isArray(value)) { return []; } return Array.from( new Set( value .map((entry) => resolvePendingUserInputOptionValue(question, entry)) .filter((entry): entry is string => entry !== null), ), );} function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined,): string | ReadonlyArray<string> | null { if (draft?.attachmentsBlocked) return null; const customAnswer = question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); if (question.multiSelect) { return selectedOptionValues.length > 0 ? selectedOptionValues : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null; } return ( selectedOptionValues[0] ?? (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) );} /** Some providers settle agents through task.updated instead of task.completed. */const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([ "completed", "failed", "cancelled", "interrupted",]); function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "task.updated") { return false; } const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; return ( typeof payload?.status === "string" && (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) || (payload.timelineBypass === true && payload.status === "idle")) );} /** * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal * activity lives in the Agents sheet, not the work log. Agent lifecycle rows * pass even when bypassed or owned by another agent, because they fold into * their spawn batch rather than rendering on their own; that is how Codex * children (all bypassed) and Claude workflow members reach the batch row. * Terminal rows are kept regardless — with no Agents surface on mobile they * are the terminal signal. */function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; if (!payload) { return false; } const isTaskRow = activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.updated" || activity.kind === "task.completed"; const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; if (isTaskRow) { if (!ownedByAgent && payload.timelineBypass !== true) { return false; } // An agent's own shells stay internal; the agents themselves fold into // their batch. A bypassed batch marker keeps its terminal row. if (typeof payload.taskId === "string" && payload.agentKind === "agent") { return false; } if (ownedByAgent) { return true; } return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity)); } return payload.timelineBypass === true || ownedByAgent;} /** Agent (non-background) task.started rows seed spawn batches. */function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; return typeof payload?.taskId === "string" && payload.agentKind === "agent";} function deriveWorkLogEntries( activities: ReadonlyArray<OrchestrationThreadActivity>,): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; // Like web: an agent's task.started row anchors its batch. It has a fixed // id and timestamp, unlike progress ticks, whose stable per-task id is // rewritten with a new createdAt on every update (and would otherwise // make the batch row a "fresh" row again on each tick). if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } return collapseDerivedWorkLogEntries(entries);} /** Adapters forward unknown wire-only SDK messages (background_tasks_changed, * commands_changed, ...) as runtime warnings. The suffix comes from * describeUnknownSdkMessage in the Claude adapter; a row with no displayable * text carries nothing a user can act on, so it does not render. */function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean { return ( activity.kind === "runtime.warning" && activity.summary.endsWith("(no displayable text content)") );} function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; } const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");} const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload); function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); const toolPresentation = extractToolActivityPresentation(payload); // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.completed" || activity.kind === "task.updated"; const taskSummary = isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 ? payload.summary : null; const taskDetailAsLabel = isTaskActivity && !taskSummary && !title && typeof payload?.detail === "string" && payload.detail.length > 0 ? payload.detail : null; const taskLabel = taskSummary || taskDetailAsLabel; const taskId = isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0 ? payload.taskId : undefined; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, turnId: activity.turnId, ...(taskId ? { taskId } : {}), label: taskLabel || activity.summary, tone: activity.kind === "task.progress" ? "thinking" : activity.tone === "approval" ? "info" : activity.tone, sourceActivityKind: activity.kind, ...(() => { if (activity.kind !== "user-input.answer-submitted") return {};Lines 1–500 of 2272. Load more
import * as Option from "effect/Option"; import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Schema from "effect/Schema"; import { requestKindFromRequestType, type PendingApproval, } from "@t3tools/client-runtime/pending-requests"; import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts"; import type { OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, ToolLifecycleItemType, TurnId, UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { commandDetailRepeatsCommand, extractCommandOutputText, extractWorkLogToolLifecycleStatus, isWorktreeSetupActivity, liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, workEntryIndicatesToolFailure, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, type ToolGroupSummaryKind, type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests"; export interface PendingUserInputDraftAnswer { readonly selectedOptionValues?: ReadonlyArray<string>; readonly customAnswer?: string; readonly attachmentCount?: number; readonly attachmentsBlocked?: boolean; } export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; readonly canExpand: boolean; readonly getFullDetail: () => string | null; readonly getCopyText: () => string; readonly icon: | "agent" | "alert" | "browser" | "check" | "command" | "computer" | "edit" | "eye" | "globe" | "hammer" | "message" | "warning" | "wrench" | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; readonly lifecycleStatus?: WorkLogToolLifecycleStatus; readonly workEntry: WorkLogEntry; readonly groupedToolDetail?: boolean; readonly live?: boolean; } export interface WorkLogEntry { readonly questionAnswer?: UserInputAttachmentAnswerPayload; id: string; createdAt: string; turnId: TurnId | null; label: string; detail?: string; viewedImagePath?: string; command?: string; rawCommand?: string; changedFiles?: ReadonlyArray<string>; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; toolSurface?: import("@t3tools/contracts").ToolActivitySurface; toolIcon?: import("@t3tools/contracts").ToolActivityIcon; toolSource?: import("@t3tools/contracts").ToolActivitySource; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; sourceActivityKind?: OrchestrationThreadActivity["kind"]; toolCallId?: string; /** * One row per workflow run or per-turn batch of direct spawns, like web's * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row * also carries each agent's terminal state to derive its status label. */ agentSpawn?: { readonly workflowId: string | null; readonly agentTaskIds: ReadonlyArray<string>; readonly agents: ReadonlyArray<{ readonly title: string; readonly status: WorkLogToolLifecycleStatus | undefined; readonly detail: string | undefined; /** When this member last reported, so the card can show the newest activity. */ readonly updatedAt: string; }>; }; toolData?: unknown; } interface DerivedWorkLogEntry extends WorkLogEntry { sourceActivityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; /** The tool call that launched this agent, when the provider reports one. */ agentSpawnToolCallId?: string; isWorkflowCoordinator?: boolean; /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ isBackgroundTask?: boolean; } type RawThreadFeedEntry = | { readonly type: "message"; readonly id: string; readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; } | { readonly type: "activity"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; }; export type ThreadFeedEntry = | Extract<RawThreadFeedEntry, { type: "message" }> | { readonly type: "activity-group"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly activities: ReadonlyArray<ThreadFeedActivity>; } | { readonly type: "work-toggle"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly groupId: string; readonly hiddenCount: number; readonly expanded: boolean; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; readonly toolSurface?: WorkLogEntry["toolSurface"]; readonly toolIcon?: WorkLogEntry["toolIcon"]; readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; } | { readonly type: "turn-fold"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId; readonly label: string; readonly expanded: boolean; } | { /** * The turn's single live slot. Web keys its live tool row and its * "Thinking" row identically so the slot updates in place; here the * slot holds "Thinking" whenever no tool row is shimmering, so a tool * failing does not insert a row under the group it lives in. */ readonly type: "thinking"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; } | { /** * One batch of spawned subagents. Rendered as its own card because a * single-line tool row has no room for what the agents are doing now, * which on a phone is the one thing worth showing. */ readonly type: "agent-spawn"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; readonly expanded: boolean; readonly summary: AgentSpawnSummary; }; export interface AgentSpawnSummary { /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */ readonly title: string; /** Latest member activity while working, else the batch outcome. */ readonly status: string; readonly tone: "working" | "completed" | "failed" | "stopped"; readonly members: ReadonlyArray<{ readonly title: string; readonly status: string; readonly tone: "working" | "completed" | "failed" | "stopped"; readonly detail: string | undefined; readonly updatedAt: string; }>; } export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" >; type ThreadFeedActivityGroup = Extract<ThreadFeedEntry, { readonly type: "activity-group" }>; // These keys are immutable inputs. Weak caches release old histories with their source data. const activityEntriesCache = new WeakMap< ReadonlyArray<OrchestrationThreadActivity>, ReadonlyArray<Extract<RawThreadFeedEntry, { readonly type: "activity" }>> >(); const messageEntriesCache = new WeakMap< OrchestrationThread["messages"][number], Extract<RawThreadFeedEntry, { readonly type: "message" }> >(); const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>(); const presentedActivityGroupsCache = new WeakMap< ThreadFeedActivityGroup, { readonly unsettledTurnId: TurnId | null; readonly isWorking: boolean; readonly activeTail: boolean; readonly rows: ReadonlyArray<ThreadFeedEntry>; } >(); const turnFoldRowsCache = new WeakMap< ThreadFeedEntry, Extract<ThreadFeedEntry, { readonly type: "turn-fold" }> >(); let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null; export function isContextCompactionActivityGroup( entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, ): boolean { return ( entry.activities.length === 1 && entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction" ); } function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function resolvePendingUserInputOptionValue( question: UserInputQuestion, value: string, ): string | null { if (question.options.some((option) => option.value === value)) { return value; } const label = value.trim(); return label.length > 0 && question.options.some((option) => option.value === undefined && option.label.trim() === label) ? label : null; } function normalizeSelectedOptionValues( question: UserInputQuestion, value: ReadonlyArray<string> | undefined, ): ReadonlyArray<string> { if (!Array.isArray(value)) { return []; } return Array.from( new Set( value .map((entry) => resolvePendingUserInputOptionValue(question, entry)) .filter((entry): entry is string => entry !== null), ), ); } function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, ): string | ReadonlyArray<string> | null { if (draft?.attachmentsBlocked) return null; const customAnswer = question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); if (question.multiSelect) { return selectedOptionValues.length > 0 ? selectedOptionValues : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null; } return ( selectedOptionValues[0] ?? (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) ); } /** Some providers settle agents through task.updated instead of task.completed. */ const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([ "completed", "failed", "cancelled", "interrupted", ]); function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "task.updated") { return false; } const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; return ( typeof payload?.status === "string" && (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) || (payload.timelineBypass === true && payload.status === "idle")) ); } /** * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal * activity lives in the Agents sheet, not the work log. Agent lifecycle rows * pass even when bypassed or owned by another agent, because they fold into * their spawn batch rather than rendering on their own; that is how Codex * children (all bypassed) and Claude workflow members reach the batch row. * Terminal rows are kept regardless — with no Agents surface on mobile they * are the terminal signal. */ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; if (!payload) { return false; } const isTaskRow = activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.updated" || activity.kind === "task.completed"; const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; if (isTaskRow) { if (!ownedByAgent && payload.timelineBypass !== true) { return false; } // An agent's own shells stay internal; the agents themselves fold into // their batch. A bypassed batch marker keeps its terminal row. if (typeof payload.taskId === "string" && payload.agentKind === "agent") { return false; } if (ownedByAgent) { return true; } return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity)); } return payload.timelineBypass === true || ownedByAgent; } /** Agent (non-background) task.started rows seed spawn batches. */ function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; return typeof payload?.taskId === "string" && payload.agentKind === "agent"; } function deriveWorkLogEntries( activities: ReadonlyArray<OrchestrationThreadActivity>, ): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; // Like web: an agent's task.started row anchors its batch. It has a fixed // id and timestamp, unlike progress ticks, whose stable per-task id is // rewritten with a new createdAt on every update (and would otherwise // make the batch row a "fresh" row again on each tick). if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } return collapseDerivedWorkLogEntries(entries); } /** Adapters forward unknown wire-only SDK messages (background_tasks_changed, * commands_changed, ...) as runtime warnings. The suffix comes from * describeUnknownSdkMessage in the Claude adapter; a row with no displayable * text carries nothing a user can act on, so it does not render. */ function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean { return ( activity.kind === "runtime.warning" && activity.summary.endsWith("(no displayable text content)") ); } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; } const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload); function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record<string, unknown>) : null; const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); const toolPresentation = extractToolActivityPresentation(payload); // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.completed" || activity.kind === "task.updated"; const taskSummary = isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 ? payload.summary : null; const taskDetailAsLabel = isTaskActivity && !taskSummary && !title && typeof payload?.detail === "string" && payload.detail.length > 0 ? payload.detail : null; const taskLabel = taskSummary || taskDetailAsLabel; const taskId = isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0 ? payload.taskId : undefined; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, turnId: activity.turnId, ...(taskId ? { taskId } : {}), label: taskLabel || activity.summary, tone: activity.kind === "task.progress" ? "thinking" : activity.tone === "approval" ? "info" : activity.tone, sourceActivityKind: activity.kind, ...(() => { if (activity.kind !== "user-input.answer-submitted") return {};