Skip to content

Commit 095923f

Browse files
feat(mobile): surface prominent activity status and metadata
- Keep prominent activity rows visible with lifecycle status and provider metadata - Move feed sizing logic into tested helpers and preserve native measurement for activity groups
1 parent 5d38597 commit 095923f

7 files changed

Lines changed: 152 additions & 49 deletions

‎apps/mobile/src/features/threads/ThreadFeed.tsx‎

Lines changed: 8 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,7 @@ import { uuidv4 } from "../../lib/uuid";
106106
import {
107107
resolveMarkdownFontSizes,
108108
resolveNativeMarkdownTypography,
109-
scaledTypographyLineHeight,
110109
} from "../../lib/appearancePreferences";
111-
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
112110
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
113111
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
114112
import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
@@ -128,13 +126,8 @@ import {
128126
resolveThreadFeedLiveFollow,
129127
type ThreadFeedLiveFollowEvent,
130128
} from "./thread-feed-live-follow";
131-
import {
132-
collapsedWorkLogHeight,
133-
ThreadDisclosureChevron,
134-
ThreadWorkGroupToggle,
135-
ThreadWorkLog,
136-
WORK_GROUP_TOGGLE_HEIGHT,
137-
} from "./thread-work-log";
129+
import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
130+
import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
138131
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
139132
import { useAssetUrl } from "../../state/assets";
140133
import { appAtomRegistry } from "../../state/atom-registry";
@@ -165,16 +158,6 @@ function formatMessageTime(input: string): string {
165158
return MESSAGE_TIME_FORMATTER.format(timestamp);
166159
}
167160

168-
// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
169-
// classNames. The fold row's min-h-11 (44px) stays taller than its single
170-
// text-sm line at every supported base font size (26px at the 22pt maximum),
171-
// so its height is a constant; a drifted value costs one correction on
172-
// measure, not a persistent offset.
173-
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
174-
// The working row has no min-height clamp — its height follows the scaled
175-
// text-xs line height (see workingRowHeight in ThreadFeed).
176-
const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16)
177-
178161
// Entering animations must only play for rows born just now — LegendList
179162
// remounts rows when they scroll back into view, and replaying an entrance for
180163
// old content would be its own kind of jank.
@@ -2244,34 +2227,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22442227
// scrolling up through unmeasured content corrects each row's height as it
22452228
// mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
22462229
// exact; message rows stay undefined and use LegendList's per-type running
2247-
// average once one of their type has been measured. Text-driven heights
2248-
// follow the configurable base font size via scaledTypographyLineHeight.
2249-
const workingRowHeight =
2250-
WORKING_ROW_VERTICAL_EXTRAS +
2251-
scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize);
2230+
// average once one of their type has been measured. Activity groups are
2231+
// also content-driven: prominent v2 items add a card and related-thread
2232+
// link, so marking them fixed would make LegendList skip their measurement
2233+
// and position the next message inside the card.
22522234
const getFixedItemSize = useCallback(
2253-
(entry: ThreadFeedEntry) => {
2254-
if (workRowSizing.fixedRowHeight === undefined) {
2255-
return undefined;
2256-
}
2257-
switch (entry.type) {
2258-
case "run-fold":
2259-
return TURN_FOLD_HEIGHT;
2260-
case "work-toggle":
2261-
return WORK_GROUP_TOGGLE_HEIGHT;
2262-
case "working":
2263-
return workingRowHeight;
2264-
case "activity-group":
2265-
// Expanded rows append a variable detail block — fall back to
2266-
// measurement for those groups.
2267-
return entry.activities.some((activity) => expandedWorkRows[activity.id])
2268-
? undefined
2269-
: collapsedWorkLogHeight(entry.activities, appearance.baseFontSize);
2270-
default:
2271-
return undefined;
2272-
}
2273-
},
2274-
[expandedWorkRows, workingRowHeight, appearance.baseFontSize],
2235+
(entry: ThreadFeedEntry) => resolveThreadFeedFixedItemSize(entry.type, appearance.baseFontSize),
2236+
[appearance.baseFontSize],
22752237
);
22762238

22772239
// Disclosures can mount existing offscreen rows as well as new work rows.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
2+
import { describe, expect, it } from "vite-plus/test";
3+
4+
import {
5+
resolveThreadActivityMetadata,
6+
resolveThreadActivityStatus,
7+
} from "./thread-activity-row-presentation";
8+
9+
describe("thread activity row presentation", () => {
10+
it("shows the provider and model as compact metadata", () => {
11+
expect(
12+
resolveThreadActivityMetadata({
13+
providerDriver: ProviderDriverKind.make("codex"),
14+
providerInstanceId: ProviderInstanceId.make("codex"),
15+
model: "gpt-5.4",
16+
}),
17+
).toBe("Codex · gpt-5.4");
18+
});
19+
20+
it("falls back to the provider instance and removes duplicate metadata", () => {
21+
expect(
22+
resolveThreadActivityMetadata({
23+
providerDriver: null,
24+
providerInstanceId: ProviderInstanceId.make("custom-agent"),
25+
model: "custom-agent",
26+
}),
27+
).toBe("custom-agent");
28+
});
29+
30+
it("maps lifecycle status to dot tone and an accessible label", () => {
31+
expect(resolveThreadActivityStatus("running")).toEqual({ label: "Running", tone: "active" });
32+
expect(resolveThreadActivityStatus("completed")).toEqual({
33+
label: "Completed",
34+
tone: "success",
35+
});
36+
expect(resolveThreadActivityStatus("failed")).toEqual({ label: "Failed", tone: "danger" });
37+
expect(resolveThreadActivityStatus("interrupted")).toEqual({
38+
label: "Interrupted",
39+
tone: "warning",
40+
});
41+
});
42+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
PROVIDER_DISPLAY_NAMES,
3+
type OrchestrationV2TurnItem,
4+
type ProviderDriverKind,
5+
type ProviderInstanceId,
6+
} from "@t3tools/contracts";
7+
8+
export function resolveThreadActivityMetadata(input: {
9+
readonly providerDriver: ProviderDriverKind | null;
10+
readonly providerInstanceId: ProviderInstanceId | null;
11+
readonly model: string | null;
12+
}): string {
13+
const providerLabel = input.providerDriver
14+
? (PROVIDER_DISPLAY_NAMES[input.providerDriver] ?? input.providerInstanceId)
15+
: input.providerInstanceId;
16+
const values = [providerLabel, input.model].filter(
17+
(value): value is string => value !== null && value.length > 0,
18+
);
19+
return [...new Set(values)].join(" · ");
20+
}
21+
22+
export function resolveThreadActivityStatus(status: OrchestrationV2TurnItem["status"]): {
23+
readonly label: string;
24+
readonly tone: "active" | "danger" | "success" | "warning";
25+
} {
26+
const label = status.charAt(0).toUpperCase() + status.slice(1).replaceAll("_", " ");
27+
switch (status) {
28+
case "completed":
29+
return { label, tone: "success" };
30+
case "failed":
31+
return { label, tone: "danger" };
32+
case "cancelled":
33+
case "interrupted":
34+
return { label, tone: "warning" };
35+
case "pending":
36+
case "running":
37+
case "waiting":
38+
return { label, tone: "active" };
39+
}
40+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
4+
5+
describe("resolveThreadFeedFixedItemSize", () => {
6+
it("leaves activity groups to native measurement", () => {
7+
expect(resolveThreadFeedFixedItemSize("activity-group", 16)).toBeUndefined();
8+
});
9+
10+
it("keeps fixed timeline chrome on the premeasured path", () => {
11+
expect(resolveThreadFeedFixedItemSize("run-fold", 16)).toBe(56);
12+
expect(resolveThreadFeedFixedItemSize("work-toggle", 16)).toBe(36);
13+
expect(resolveThreadFeedFixedItemSize("working", 16)).toBeGreaterThan(24);
14+
});
15+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { ThreadFeedEntry } from "../../lib/threadActivity";
2+
import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
3+
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
4+
5+
// These rows are pure timeline chrome whose rendered height is independent of
6+
// their content. Content-driven rows must be measured by LegendList: returning
7+
// a fixed size makes the list skip native measurement entirely.
8+
const TURN_FOLD_HEIGHT = 56;
9+
const WORK_GROUP_TOGGLE_HEIGHT = 36;
10+
const WORKING_ROW_VERTICAL_EXTRAS = 24;
11+
12+
export function resolveThreadFeedFixedItemSize(
13+
entryType: ThreadFeedEntry["type"],
14+
baseFontSize: number,
15+
): number | undefined {
16+
switch (entryType) {
17+
case "run-fold":
18+
return TURN_FOLD_HEIGHT;
19+
case "work-toggle":
20+
return WORK_GROUP_TOGGLE_HEIGHT;
21+
case "working":
22+
return (
23+
WORKING_ROW_VERTICAL_EXTRAS +
24+
scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, baseFontSize)
25+
);
26+
case "activity-group":
27+
case "message":
28+
return undefined;
29+
}
30+
}

‎apps/mobile/src/lib/threadActivity.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { describe, expect, it } from "vite-plus/test";
1212
import {
1313
buildThreadFeed,
1414
deriveThreadFeedPresentation,
15+
threadFeedActivityIsVisible,
1516
threadFeedRunIsUnsettled,
1617
type ThreadFeedActivity,
1718
type ThreadFeedEntry,
@@ -93,6 +94,15 @@ function assistantMessage(updatedAt = "2026-06-20T00:00:03.000Z") {
9394
}
9495

9596
describe("buildThreadFeed", () => {
97+
it("keeps prominent activity visible while it is running", () => {
98+
expect(
99+
threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }),
100+
).toBe(true);
101+
expect(
102+
threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }),
103+
).toBe(false);
104+
});
105+
96106
it("hides synthetic workspace preparation activity", () => {
97107
const workspacePreparation = projected(
98108
{

‎apps/mobile/src/lib/threadActivity.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,12 @@ export function threadFeedRunIsUnsettled(
475475
);
476476
}
477477

478+
export function threadFeedActivityIsVisible(
479+
activity: Pick<ThreadFeedActivity, "prominent" | "status" | "toolLike">,
480+
): boolean {
481+
return activity.prominent || !(activity.toolLike && activity.status === "neutral");
482+
}
483+
478484
interface ThreadFeedRunFold {
479485
readonly runId: RunId;
480486
readonly createdAt: string;
@@ -634,9 +640,7 @@ function appendPresentedFeedEntry(
634640
return;
635641
}
636642

637-
const activities = entry.activities.filter(
638-
(activity) => !(activity.toolLike && activity.status === "neutral"),
639-
);
643+
const activities = entry.activities.filter(threadFeedActivityIsVisible);
640644
if (activities.length === 0) {
641645
return;
642646
}

0 commit comments

Comments
 (0)