diff --git a/App.tsx b/App.tsx index f9c1f76..db26af5 100644 --- a/App.tsx +++ b/App.tsx @@ -38,8 +38,11 @@ import { } from "./src/ui/kit"; import { Artifacts, CapabilityApproval } from "./src/ui/Artifacts"; import type { ChatMessage } from "./src/runtime/engine"; -import type { ActivityMessage } from "./src/ui/chat-activity"; -import { ActivityRow } from "./src/ui/ActivityRow"; +import { + groupActivities, + type ActivityGroupMessage, +} from "./src/ui/chat-activity"; +import { ActivityGroup } from "./src/ui/ActivityGroup"; type Tab = "chat" | "tasks" | "skills" | "settings"; const tabs: { id: Tab; label: string; icon: IconName }[] = [ @@ -71,7 +74,8 @@ function MooseApp() { const [artifactsVisible, setArtifactsVisible] = useState(false); const [keyboardVisible, setKeyboardVisible] = useState(false); const followBottom = useRef(true); - const list = useRef>(null); + const list = useRef>(null); + const activityMessages = groupActivities(moose.messages, moose.activity); const title = moose.conversations.find((item) => item.id === moose.conversationId) ?.title ?? "New conversation"; @@ -276,9 +280,9 @@ function MooseApp() { style={{ flex: 1, minHeight: 0 }} removeClippedSubviews={false} ref={list} - data={moose.messages} + data={activityMessages} keyExtractor={(item, index) => - item.activity?.id ?? `${item.entryId}:${index}` + `${moose.conversationId}:${item.groupId ?? `${item.entryId}:${index}`}` } keyboardShouldPersistTaps="handled" keyboardDismissMode={ @@ -320,9 +324,9 @@ function MooseApp() { ) : undefined } renderItem={({ item }) => - item.activity ? ( + item.activities ? ( - + ) : ( {moose.executionLabel} · Details )} - {moose.activity.map((activity) => ( - - ))} {!!moose.partial && moose.busy && ( )} diff --git a/docs/chat-layout.md b/docs/chat-layout.md index 52f4f15..b98beec 100644 --- a/docs/chat-layout.md +++ b/docs/chat-layout.md @@ -49,3 +49,11 @@ Temporary local user/assistant samples exercised the actual message renderer whi Run `python3 scripts/check-android-composer.py emulator-5554` with the keyboard open, or add `--closed` with tabs visible. Add `--empty` for an empty draft to check the compact row. These checks inspect native bounds; they do not send messages. Final source checks: 27 Vitest files passed (206 tests passed, 8 skipped), TypeScript and Prettier passed, and iOS/Android Hermes release assets exported successfully. The clean production source launched in the Android Debug client and passed the empty portrait composer bounds check after removing the samples. No iOS native compilation or device run was performed. + +## Compact activity disclosure + +Each user turn has one activity disclosure at the position of its first tool call. New live activity replaces the collapsed label rather than adding rows; running work takes precedence over queued calls. After completion, the summary shows the number of recorded activities and indicates failures. Duration is omitted because the presentation data does not provide reliable start/end times. Assistant prose retains its transcript order. + +Tap the summary to reveal all recorded activity labels and their bounded text output. Individual output sections can still be collapsed. The summary uses a gentle opacity pulse while active, respects the system Reduce Motion preference, and stops animating on completion or unmount. Its accessible state identifies expansion and active work, with polite status announcements. Switching conversations resets disclosure state. Existing committed tool results remain available after reopening; nested live progress retains its existing transient lifetime and output remains limited to the last 4,000 characters. + +Validation: 231 tests passed and 8 skipped; type checking, runtime bundle generation, formatting, and whitespace checks passed. A browser preview of the actual React Native Web components verified a single collapsed row, one-tap expansion including output, completion replacing the label while preserving expansion, the active opacity pulse stopping on completion, and a static summary with reduced motion enabled. The temporary browser harness used Expo's non-server font context and browser globals; no application dependency or configuration changed. A follow-up native component check used existing Android and iOS development clients with a temporary controlled-data preview. Android emulator interaction verified single-row running state, one-tap expansion with all three outputs, completion and recollapse; an iPhone 17 Pro simulator verified running-state rendering. Screenshots are in `docs/screenshots/pr-24/`. The temporary preview was removed. This does not establish full conversation/runtime integration or screen-reader behavior. No new native compilation, physical-device testing, release asset export, or real model request was performed. diff --git a/docs/screenshots/pr-24/android-completed.png b/docs/screenshots/pr-24/android-completed.png new file mode 100644 index 0000000..be167da Binary files /dev/null and b/docs/screenshots/pr-24/android-completed.png differ diff --git a/docs/screenshots/pr-24/android-expanded.png b/docs/screenshots/pr-24/android-expanded.png new file mode 100644 index 0000000..f4922a5 Binary files /dev/null and b/docs/screenshots/pr-24/android-expanded.png differ diff --git a/docs/screenshots/pr-24/android-running.png b/docs/screenshots/pr-24/android-running.png new file mode 100644 index 0000000..8f4e46d Binary files /dev/null and b/docs/screenshots/pr-24/android-running.png differ diff --git a/docs/screenshots/pr-24/ios-running.png b/docs/screenshots/pr-24/ios-running.png new file mode 100644 index 0000000..39d92a7 Binary files /dev/null and b/docs/screenshots/pr-24/ios-running.png differ diff --git a/docs/superpowers/plans/2026-10-23-compact-tool-activity.md b/docs/superpowers/plans/2026-10-23-compact-tool-activity.md new file mode 100644 index 0000000..6c0af9d --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-compact-tool-activity.md @@ -0,0 +1,14 @@ +# Compact tool activity + +Goal: replace the waterfall of tool status rows with one expandable summary per user turn. + +Design: derive groups from the existing transcript and live projection, without new persistence or scheduling. Keep assistant prose in transcript order and put the summary at the first activity position of each turn. Append live activity to the current turn's group. While active, show the latest running activity (prefer running over queued entries); otherwise show the number of recorded activities, with failure indicated. Do not invent elapsed duration when the transcript does not supply it. Expansion reveals every recorded activity and its existing bounded output. Use a subtle opacity pulse, respect Reduce Motion, and stop animation on completion/unmount. Reset expansion when switching conversations. + +Acceptance: multiple tools collapse to one summary, completion updates the same group, user turns remain separate, interleaved assistant prose is retained, live-only states work, failed calls remain discoverable, and input arrays remain untouched. Existing output truncation and credential boundaries remain unchanged. Nested live details have the existing transient lifetime. + +Plan: + +- [x] Add failing projection tests for grouping, order, latest activity and live transitions. +- [x] Implement pure grouping and summary helpers in src/ui/chat-activity.ts. +- [x] Add ActivityGroup with accessible disclosure and Reduce Motion-aware pulse; wire App.tsx. +- [x] Run tests, typecheck and formatting; attempt actual component preview; document validation limits and open PR. diff --git a/src/ui/ActivityGroup.tsx b/src/ui/ActivityGroup.tsx new file mode 100644 index 0000000..7d38997 --- /dev/null +++ b/src/ui/ActivityGroup.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + AccessibilityInfo, + Animated, + Pressable, + Text, + View, +} from "react-native"; +import { ActivityRow } from "./ActivityRow"; +import { activitySummary, type Activity } from "./chat-activity"; +import { colors, Icon } from "./kit"; + +export function ActivityGroup({ activities }: { activities: Activity[] }) { + const [expanded, setExpanded] = useState(false); + const [reduceMotion, setReduceMotion] = useState(true); + const opacity = useRef(new Animated.Value(1)).current; + const summary = activitySummary(activities); + useEffect(() => { + let mounted = true; + let changed = false; + const listener = AccessibilityInfo.addEventListener( + "reduceMotionChanged", + (value) => { + changed = true; + setReduceMotion(value); + }, + ); + void AccessibilityInfo.isReduceMotionEnabled() + .then((value) => { + if (mounted && !changed) setReduceMotion(value); + }) + .catch(() => {}); + return () => { + mounted = false; + listener.remove(); + }; + }, []); + useEffect(() => { + if (!summary.active || reduceMotion) { + opacity.setValue(1); + return; + } + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 0.55, + duration: 900, + useNativeDriver: true, + isInteraction: false, + }), + Animated.timing(opacity, { + toValue: 1, + duration: 900, + useNativeDriver: true, + isInteraction: false, + }), + ]), + ); + pulse.start(); + return () => { + pulse.stop(); + opacity.setValue(1); + }; + }, [summary.active, reduceMotion, opacity]); + const color = summary.error ? colors.danger : colors.muted; + return ( + + setExpanded((value) => !value)} + style={{ minHeight: 44, justifyContent: "center" }} + > + + + {summary.label} + + + + + {expanded && ( + + {activities.map((activity) => ( + + ))} + + )} + + ); +} diff --git a/src/ui/ActivityRow.tsx b/src/ui/ActivityRow.tsx index 2e1e643..3029164 100644 --- a/src/ui/ActivityRow.tsx +++ b/src/ui/ActivityRow.tsx @@ -3,8 +3,14 @@ import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { colors, Icon } from "./kit"; import type { Activity } from "./chat-activity"; -export function ActivityRow({ activity }: { activity: Activity }) { - const [expanded, setExpanded] = useState(false); +export function ActivityRow({ + activity, + initiallyExpanded = false, +}: { + activity: Activity; + initiallyExpanded?: boolean; +}) { + const [expanded, setExpanded] = useState(initiallyExpanded); const color = activity.error ? colors.danger : colors.muted; const label = ( diff --git a/src/ui/chat-activity.ts b/src/ui/chat-activity.ts index bc4d748..138e458 100644 --- a/src/ui/chat-activity.ts +++ b/src/ui/chat-activity.ts @@ -114,3 +114,57 @@ export function chatActivity(live?: LiveState): Activity[] { } return rows; } + +export type ActivityGroupMessage = ActivityMessage & { + groupId?: string; + activities?: Activity[]; +}; + +// A turn owns one disclosure even when assistant prose separates its tool calls. +export function groupActivities( + messages: ActivityMessage[], + live: Activity[], +): ActivityGroupMessage[] { + const result: ActivityGroupMessage[] = []; + let turn = 0; + let group: ActivityGroupMessage | undefined; + function append(activity: Activity, entryId: number) { + if (!group) { + group = { + entryId, + role: "assistant", + text: "", + groupId: `activity:${turn}`, + activities: [], + }; + result.push(group); + } + group.activities!.push(activity); + } + for (const message of messages) { + if (message.role === "user") { + turn = message.entryId; + group = undefined; + } + if (message.activity) append(message.activity, message.entryId); + else result.push(message); + } + for (const activity of live) append(activity, turn); + return result; +} + +export function activitySummary(activities: Activity[]) { + const active = activities.filter((activity) => activity.active); + const current = + active + .filter((activity) => !activity.label.endsWith(" · Pending")) + .at(-1) ?? active.at(-1); + const error = activities.some((activity) => activity.error); + return { + label: + current?.label ?? + `Worked · ${activities.length} ${activities.length === 1 ? "activity" : "activities"}${error ? " · Failed" : ""}`, + active: !!current, + error: !current && error, + }; +} diff --git a/tests/activity-groups.test.ts b/tests/activity-groups.test.ts new file mode 100644 index 0000000..b5eb90f --- /dev/null +++ b/tests/activity-groups.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; +import { + groupActivities, + activitySummary, + type ActivityMessage, +} from "../src/ui/chat-activity"; +const user = (entryId: number): ActivityMessage => ({ + entryId, + role: "user", + text: "Question", +}); +const tool = (id: string, entryId: number, error = false): ActivityMessage => ({ + entryId, + role: "assistant", + text: id, + activity: { + id, + label: `${id} · ${error ? "Failed" : "Completed"}`, + output: id, + error, + }, +}); +it("collapses a turn's tools while preserving interleaved prose and separate turns", () => { + const prose: ActivityMessage = { + entryId: 3, + role: "assistant", + text: "Progress", + }; + const input = [ + user(1), + tool("a", 2), + prose, + tool("b", 4), + user(5), + tool("c", 6), + ]; + const before = JSON.stringify(input); + const result = groupActivities(input, []); + expect(result.map((x) => x.activities?.map((a) => a.id) ?? x.text)).toEqual([ + "Question", + ["a", "b"], + "Progress", + "Question", + ["c"], + ]); + expect(JSON.stringify(input)).toBe(before); +}); +it("reuses the first activity identity as live calls complete and supports live-only turns", () => { + const live = [{ id: "b", label: "bash · Running", active: true }]; + const running = groupActivities([user(1), tool("a", 2)], live); + expect(running[1].activities?.map((x) => x.id)).toEqual(["a", "b"]); + expect( + groupActivities([user(1), tool("a", 2), tool("b", 3)], [])[1] + .activities?.[0].id, + ).toBe(running[1].activities?.[0].id); + expect(groupActivities([user(1)], live)[1].activities).toEqual(live); + expect(groupActivities([], [])).toEqual([]); +}); +it("shows current running work instead of queued work and exposes failures on completion", () => { + expect( + activitySummary([ + { id: "a", label: "read · Running", active: true }, + { id: "b", label: "bash · Pending", active: true }, + ]), + ).toEqual({ label: "read · Running", active: true, error: false }); + expect( + activitySummary([ + { id: "a", label: "read · Completed" }, + { id: "b", label: "bash · Failed", error: true }, + ]), + ).toEqual({ + label: "Worked · 2 activities · Failed", + active: false, + error: true, + }); + expect( + activitySummary([{ id: "a", label: "Thinking", active: true }]).label, + ).toBe("Thinking"); +}); +it("never attaches new live work to a previous user turn", () => { + const result = groupActivities( + [user(1), tool("a", 2), user(3)], + [{ id: "new", label: "Thinking", active: true }], + ); + expect(result[1].activities?.map((x) => x.id)).toEqual(["a"]); + expect(result[3].activities?.map((x) => x.id)).toEqual(["new"]); +}); + +it("keeps the disclosure key stable from live-only work to committed results", () => { + const live = [{ id: "tool:call", label: "read · Running", active: true }]; + const running = groupActivities([user(10)], live); + const completed = groupActivities([user(10), tool("result:call", 11)], []); + expect(running[1].groupId).toBe("activity:10"); + expect(completed[1].groupId).toBe(running[1].groupId); + expect(activitySummary(completed[1].activities!).active).toBe(false); +});