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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { AppSymbolName } from "../../components/ui/AppSymbol";
import type { ThreadActivityIcon } from "../../lib/threadActivityIcons";

export function workRowSymbolName(
icon: ThreadActivityIcon,
): AppSymbolName | "browser" | "computer" {
switch (icon) {
case "agent":
return { ios: "sparkles", android: "auto_awesome" };
case "alert":
return { ios: "exclamationmark.triangle", android: "error" };
case "browser":
return { ios: "safari", android: "public" };
case "check":
return { ios: "checkmark", android: "check" };
case "command":
return { ios: "terminal", android: "terminal" };
case "computer":
return "computer";
case "code":
return "chevron.left.forwardslash.chevron.right";
case "edit":
return { ios: "square.and.pencil", android: "edit" };
case "eye":
return { ios: "eye", android: "visibility" };
case "folder":
return { ios: "folder", android: "folder" };
case "globe":
return { ios: "globe", android: "public" };
case "search":
return "magnifyingglass";
case "stop":
return "stop.fill";
case "hammer":
return { ios: "hammer", android: "construction" };
case "lock":
return { ios: "lock", android: "lock" };
case "message":
return { ios: "bubble.left", android: "chat_bubble" };
case "warning":
return { ios: "xmark", android: "close" };
case "wrench":
return { ios: "wrench", android: "build" };
case "zap":
return { ios: "bolt", android: "bolt" };
}
}
42 changes: 1 addition & 41 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { workRowSymbolName } from "./thread-work-log-icons";
import { Button } from "../../components/ui/Button";
import { SubagentStatusDot } from "./SubagentStatusDot";
import { ThreadSubagentGroup } from "./thread-subagent-group";
Expand Down Expand Up @@ -402,47 +403,6 @@ export function ShimmeringWorkContent(props: {
);
}

function workRowSymbolName(icon: ThreadFeedActivity["icon"]): WorkContentIcon {
switch (icon) {
case "agent":
return { ios: "sparkles", android: "auto_awesome" };
case "alert":
return { ios: "exclamationmark.triangle", android: "error" };
case "browser":
return { ios: "safari", android: "public" };
case "check":
return { ios: "checkmark", android: "check" };
case "command":
return { ios: "terminal", android: "terminal" };
case "computer":
return "computer";
case "code":
return "chevron.left.forwardslash.chevron.right";
case "edit":
return { ios: "square.and.pencil", android: "edit" };
case "eye":
return { ios: "eye", android: "visibility" };
case "folder":
return { ios: "folder", android: "folder" };
case "globe":
return { ios: "globe", android: "public" };
case "search":
return "magnifyingglass";
case "hammer":
return { ios: "hammer", android: "construction" };
case "lock":
return { ios: "lock", android: "lock" };
case "message":
return { ios: "bubble.left", android: "chat_bubble" };
case "warning":
return { ios: "xmark", android: "close" };
case "wrench":
return { ios: "wrench", android: "build" };
case "zap":
return { ios: "bolt", android: "bolt" };
}
}

// Animate only fresh rows; virtualization remounts old rows during scrolling.
const FRESH_ROW_WINDOW_MS = 3_000;
function isFreshRow(createdAt: string): boolean {
Expand Down
106 changes: 2 additions & 104 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { itemIcon, type ThreadActivityIcon } from "./threadActivityIcons";
import {
resolvePendingUserInputAnswer as resolveSharedAnswer,
setPendingUserInputCustomAnswer as setSharedCustomAnswer,
Expand Down Expand Up @@ -72,7 +73,6 @@ import {
classifyToolActivity,
collectToolFilePaths,
dynamicToolTitle,
isJavaScriptReplTool,
formatReadToolLabel,
formatSearchToolLabel,
} from "@goat/shared/toolActivity";
Expand Down Expand Up @@ -105,25 +105,7 @@ export interface ThreadFeedActivity {
readonly fetchesDetail: boolean;
readonly getFullDetail: () => string | null;
readonly getCopyText: () => string;
readonly icon:
| "agent"
| "alert"
| "browser"
| "computer"
| "code"
| "check"
| "command"
| "edit"
| "eye"
| "folder"
| "globe"
| "search"
| "hammer"
| "lock"
| "message"
| "warning"
| "wrench"
| "zap";
readonly icon: ThreadActivityIcon;
readonly logo: GoatMcpToolLogo | null;
readonly toolLike: boolean;
readonly prominent: boolean;
Expand Down Expand Up @@ -627,90 +609,6 @@ function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntr
}
}

function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
if (item.type === "system_notice" && item.severity === "info") return "eye";
if (item.type === "notification") {
const source = item.source;
switch (source.kind) {
case "subagent":
case "delegated_task":
return "hammer";
case "command":
return "command";
case "monitor":
return "eye";
case "question":
return "message";
case "background_task":
case "system":
return "zap";
default:
source satisfies never;
return "zap";
}
}
if (item.type === "dynamic_tool") {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
data: { toolName: item.toolName ?? undefined, input: item.input },
});
if (classified === "read") {
return "eye";
}
if (classified === "search") return "search";
}
if (item.type === "command_execution" && item.commandActions?.length) {
const kinds = new Set(item.commandActions.map((action) => action.type));
if (kinds.size === 1 && kinds.has("read")) return "eye";
if (kinds.size === 1 && kinds.has("list")) return "folder";
return "search";
}
switch (item.type) {
case "reasoning":
return "agent";
case "command_execution":
return "command";
case "file_change":
return "edit";
case "file_search":
return "search";
case "web_search":
return "globe";
case "approval_request":
return item.requestKind === "permission" ? "lock" : "message";
case "user_input_request":
case "user_message":
case "assistant_message":
return "message";
case "dynamic_tool":
return isJavaScriptReplTool(item.toolName) ? "code" : "wrench";
case "subagent":
return "hammer";
case "run_interrupt_request":
case "run_interrupt_result":
case "system_notice":
return "warning";
case "error":
return item.failure.class === "usage_limit"
? item.status === "completed"
? "check"
: "warning"
: "alert";
case "checkpoint":
case "proposed_plan":
case "todo_list":
return "check";
case "compaction":
case "handoff":
case "fork":
case "thread_created":
return "zap";
case "human_handoff":
case "secret_request":
return "lock";
}
}

function itemToolPresentation(item: OrchestrationV2TurnItem): GoatMcpToolPresentation | null {
if (item.type !== "dynamic_tool") {
return null;
Expand Down
74 changes: 74 additions & 0 deletions apps/mobile/src/lib/threadActivityIcons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { RunId, ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@goat/contracts";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

import { workRowSymbolName } from "../features/threads/thread-work-log-icons";
import { buildThreadFeed } from "./threadActivity";
import { itemIcon } from "./threadActivityIcons";

const at = DateTime.makeUnsafe("2026-10-28T00:00:00.000Z");
const itemBase = {
id: TurnItemId.make("item-1"),
threadId: ThreadId.make("thread-1"),
runId: RunId.make("run-1"),
nodeId: null,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 1,
status: "interrupted" as const,
title: null,
startedAt: at,
completedAt: at,
updatedAt: at,
};

describe("mobile interruption presentation", () => {
it.each(["run_interrupt_request", "run_interrupt_result"] as const)(
"uses a stop symbol for %s",
(type) => {
const item: OrchestrationV2TurnItem = { ...itemBase, type, message: "Stopped by user" };
expect(workRowSymbolName(itemIcon(item))).toBe("stop.fill");
},
);

it("keeps an interrupted run neutral and stopped in the feed", () => {
const item: OrchestrationV2TurnItem = {
...itemBase,
type: "run_interrupt_result",
message: "Stopped by user",
};
const activities = buildThreadFeed([
{
item,
position: 0,
visibility: "local",
sourceThreadId: item.threadId,
sourceItemId: item.id,
},
]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
expect(activities).toMatchObject([
{ icon: "stop", status: null, lifecycleStatus: "stopped", summary: "Run interrupted" },
]);
});

it("retains warning and error icons for notices and provider failures", () => {
expect(
itemIcon({ ...itemBase, type: "system_notice", severity: "warning", message: "Notice" }),
).toBe("warning");
expect(
itemIcon({
...itemBase,
type: "error",
status: "failed",
failure: {
class: "provider_error",
message: "Provider failed",
code: null,
retryable: true,
},
}),
).toBe("alert");
});
});
Loading
Loading