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
24 changes: 11 additions & 13 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [
Expand Down Expand Up @@ -71,7 +74,8 @@ function MooseApp() {
const [artifactsVisible, setArtifactsVisible] = useState(false);
const [keyboardVisible, setKeyboardVisible] = useState(false);
const followBottom = useRef(true);
const list = useRef<FlatList<ActivityMessage>>(null);
const list = useRef<FlatList<ActivityGroupMessage>>(null);
const activityMessages = groupActivities(moose.messages, moose.activity);
const title =
moose.conversations.find((item) => item.id === moose.conversationId)
?.title ?? "New conversation";
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -320,9 +324,9 @@ function MooseApp() {
) : undefined
}
renderItem={({ item }) =>
item.activity ? (
item.activities ? (
<View style={[a.message, a.assistantMessage]}>
<ActivityRow activity={item.activity} />
<ActivityGroup activities={item.activities} />
</View>
) : (
<View
Expand Down Expand Up @@ -377,12 +381,6 @@ function MooseApp() {
<Caption>{moose.executionLabel} · Details</Caption>
</Pressable>
)}
{moose.activity.map((activity) => (
<ActivityRow
key={`${moose.conversationId}:${activity.id}`}
activity={activity}
/>
))}
{!!moose.partial && moose.busy && (
<Markdown text={moose.partial} />
)}
Expand Down
8 changes: 8 additions & 0 deletions docs/chat-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Binary file added docs/screenshots/pr-24/android-completed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/pr-24/android-expanded.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/pr-24/android-running.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/pr-24/ios-running.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions docs/superpowers/plans/2026-10-23-compact-tool-activity.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions src/ui/ActivityGroup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View>
<Pressable
accessibilityRole="button"
accessibilityLiveRegion="polite"
accessibilityLabel={summary.label}
accessibilityHint="Expand or collapse activity details"
accessibilityState={{ expanded, busy: summary.active }}
onPress={() => setExpanded((value) => !value)}
style={{ minHeight: 44, justifyContent: "center" }}
>
<Animated.View
style={{
opacity,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Text style={{ color, fontSize: 13, flexShrink: 1 }}>
{summary.label}
</Text>
<Icon
name={expanded ? "chevron-down" : "chevron-right"}
size={14}
color={color}
/>
</Animated.View>
</Pressable>
{expanded && (
<View
style={{
paddingLeft: 8,
borderLeftWidth: 1,
borderLeftColor: colors.line,
}}
>
{activities.map((activity) => (
<ActivityRow
key={activity.id}
activity={activity}
initiallyExpanded
/>
))}
</View>
)}
</View>
);
}
10 changes: 8 additions & 2 deletions src/ui/ActivityRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
Expand Down
54 changes: 54 additions & 0 deletions src/ui/chat-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
96 changes: 96 additions & 0 deletions tests/activity-groups.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});