Skip to content

Commit 66daed0

Browse files
feat(mobile): show Pi extension statuses under the thread header
Pi extensions can publish short statuses with setStatus. Web and desktop show them beside the thread title; the mobile thread screen showed nothing. Mobile now subscribes through the shared client-runtime status atoms and renders the thread's statuses as compact chips in a row floating just under the navigation header. The feed reserves the row's height above its first row and in its send anchor, so the strip never covers the oldest loaded row, the load-earlier control or a just-sent message. Pi threads keep that band from the start, so a status appearing, changing or clearing never shifts the feed, and the composer and keyboard insets are untouched. Chips keep the server's order, lead each source with its provider icon, show tone as a theme-colored dot, meet platform touch-target sizes, and open an alert with the full text, tooltip and a note that the status can lag a session change. Nothing renders when the thread has no statuses or the server lacks the capability. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent b55a050 commit 66daed0

9 files changed

Lines changed: 436 additions & 6 deletions
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import type {
3+
ContributionStatusTone,
4+
EnvironmentId,
5+
ProviderDriverKind,
6+
ThreadId,
7+
} from "@t3tools/contracts";
8+
import { useMemo } from "react";
9+
import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
10+
11+
import { AppText as Text } from "../../components/AppText";
12+
import { ProviderIcon } from "../../components/ProviderIcon";
13+
import { cn } from "../../lib/cn";
14+
import { contributionStatusEnvironment } from "../../state/contribution-status";
15+
import {
16+
reservesContributionStatusBand,
17+
threadContributionStatusChips,
18+
threadHasContributionStatus,
19+
} from "./thread-contribution-status-presentation";
20+
21+
/** The strip's height: one row of chip touch targets (44pt iOS, 48dp Android). */
22+
const STRIP_HEIGHT = Platform.OS === "android" ? 48 : 44;
23+
24+
// Neutral, the default, has no dot: Pi status text often brings its own glyph.
25+
const TONE_DOT_CLASS = {
26+
neutral: null,
27+
info: "bg-adaptive-sky-600-400",
28+
success: "bg-adaptive-emerald-600-400",
29+
warning: "bg-adaptive-amber-700-400",
30+
error: "bg-adaptive-rose-600-400",
31+
} as const satisfies Record<ContributionStatusTone, string | null>;
32+
33+
/**
34+
* The band the feed reserves above its first row for the status strip, passed
35+
* to ThreadFeed's `topOverlayInset`. See `reservesContributionStatusBand`.
36+
*/
37+
export function useThreadContributionStatusStripInset(input: {
38+
readonly environmentId: EnvironmentId;
39+
readonly threadId: ThreadId;
40+
readonly supported: boolean;
41+
readonly driver: ProviderDriverKind | undefined;
42+
}): number {
43+
const hasStatus = useAtomValue(
44+
contributionStatusEnvironment.threadStatus(input.environmentId, input.threadId),
45+
threadHasContributionStatus,
46+
);
47+
return reservesContributionStatusBand({ ...input, hasStatus }) ? STRIP_HEIGHT : 0;
48+
}
49+
50+
/**
51+
* Advisory statuses the thread's provider set, such as Pi extension
52+
* `setStatus` text, as one row of chips floating just under the navigation
53+
* header. It overlays the feed like the header does, so a status changing
54+
* never resizes the composer; the feed reserves its band through
55+
* `useThreadContributionStatusStripInset`. Renders nothing when the server
56+
* lacks the capability or the thread has no statuses. Pressing a chip shows
57+
* its tooltip and where it came from.
58+
*/
59+
export function ThreadContributionStatusStrip(props: {
60+
readonly environmentId: EnvironmentId;
61+
readonly threadId: ThreadId;
62+
/** Distance from the screen's top edge to the bottom of the navigation header. */
63+
readonly top: number;
64+
readonly contentMaxWidth: number | undefined;
65+
}) {
66+
const entries = useAtomValue(
67+
contributionStatusEnvironment.threadStatus(props.environmentId, props.threadId),
68+
);
69+
const chips = useMemo(() => threadContributionStatusChips(entries), [entries]);
70+
if (chips.length === 0) return null;
71+
72+
return (
73+
<View
74+
pointerEvents="box-none"
75+
className="absolute inset-x-0"
76+
style={{ top: props.top, height: STRIP_HEIGHT }}
77+
>
78+
<View
79+
pointerEvents="box-none"
80+
className="w-full self-center"
81+
style={{ maxWidth: props.contentMaxWidth }}
82+
>
83+
{/* Sized to its chips, so the feed under the empty rest of the row
84+
still takes touches. */}
85+
<ScrollView
86+
horizontal
87+
showsHorizontalScrollIndicator={false}
88+
className="max-w-full grow-0 self-start"
89+
contentContainerClassName="px-2.5"
90+
>
91+
{chips.map((chip) => {
92+
const dotClass = TONE_DOT_CLASS[chip.tone];
93+
// The compact pill sits inside a full-size touch target (44pt
94+
// iOS, 48dp Android); neighbours abut without overlapping.
95+
return (
96+
<Pressable
97+
key={chip.id}
98+
accessibilityRole="button"
99+
accessibilityLabel={chip.accessibilityLabel}
100+
accessibilityHint={chip.tooltip ?? chip.help}
101+
className="min-h-[44px] min-w-[44px] items-center justify-center px-[3px] active:opacity-60 android:min-h-[48px] android:min-w-[48px]"
102+
onPress={() => Alert.alert(chip.details.title, chip.details.message)}
103+
>
104+
<View className="min-h-7 flex-row items-center gap-1.5 rounded-full border border-border-subtle bg-card-alt px-2.5">
105+
{chip.leadsSource ? <ProviderIcon provider={chip.driver} size={12} /> : null}
106+
{dotClass === null ? null : (
107+
<View className={cn("h-1.5 w-1.5 shrink-0 rounded-full", dotClass)} />
108+
)}
109+
<Text numberOfLines={1} className="text-xs text-foreground-secondary">
110+
{chip.text}
111+
</Text>
112+
</View>
113+
</Pressable>
114+
);
115+
})}
116+
</ScrollView>
117+
</View>
118+
</View>
119+
);
120+
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ import { ComposerFeedback } from "./ComposerFeedback";
119119
import { ComposerUsageLimits } from "./ComposerUsageLimits";
120120
import { PendingUserInputCard } from "./PendingUserInputCard";
121121
import { ProviderSubagentBar } from "./ProviderSubagentBar";
122+
import {
123+
ThreadContributionStatusStrip,
124+
useThreadContributionStatusStripInset,
125+
} from "./ThreadContributionStatusStrip";
122126
import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
123127
import {
124128
FLOATING_WORKING_CONTROL_COVERAGE,
@@ -814,6 +818,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
814818
providerSubagentProvider.models,
815819
)
816820
: null;
821+
const statusStripInset = useThreadContributionStatusStripInset({
822+
environmentId: props.environmentId,
823+
threadId: props.selectedThread.id,
824+
supported: props.serverConfig?.environment.capabilities.contributionStatus === true,
825+
driver: providerSubagentProvider?.driver,
826+
});
817827
const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
818828
(model) => model.slug === providerSubagentModelSlug,
819829
);
@@ -1130,6 +1140,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11301140
submittedMessageId={submittedMessageId}
11311141
contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
11321142
contentTopInset={0}
1143+
topOverlayInset={statusStripInset}
11331144
contentBottomInset={
11341145
estimatedOverlayHeight +
11351146
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
@@ -1161,6 +1172,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11611172
/>
11621173
) : null}
11631174

1175+
{showContent ? (
1176+
<ThreadContributionStatusStrip
1177+
environmentId={props.environmentId}
1178+
threadId={props.selectedThread.id}
1179+
// A transparent (glass) header overlaps the screen; an opaque one
1180+
// ends where the screen begins.
1181+
top={
1182+
props.usesAutomaticContentInsets === true && Platform.OS === "ios"
1183+
? navigationHeaderHeight
1184+
: 0
1185+
}
1186+
contentMaxWidth={contentMaxWidth}
1187+
/>
1188+
) : null}
1189+
11641190
{/* Floating composer — sticks to keyboard via KeyboardStickyView */}
11651191
{showContent ? (
11661192
<KeyboardStickyView

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ import { cn } from "../../lib/cn";
139139
import {
140140
deriveCenteredContentHorizontalPadding,
141141
deriveThreadFeedInitialContentInset,
142+
deriveThreadFeedTopGeometry,
142143
deriveThreadWorkLogSizing,
143144
type LayoutVariant,
144145
} from "../../lib/layout";
@@ -291,6 +292,8 @@ export interface ThreadFeedProps {
291292
readonly submittedMessageId: MessageId | null;
292293
readonly contentInsetEndAdjustment: SharedValue<number>;
293294
readonly contentTopInset?: number;
295+
/** Height of UI floating over the feed's top edge below the header (the status strip). */
296+
readonly topOverlayInset?: number;
294297
readonly contentBottomInset?: number;
295298
readonly historyControls?: ThreadFeedHistoryControls;
296299
readonly contentMaxWidth?: number;
@@ -2264,6 +2267,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22642267
const anchorTopInset = usesNativeAutomaticInsets
22652268
? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT
22662269
: topContentInset;
2270+
const feedTop = deriveThreadFeedTopGeometry({
2271+
usesNativeAutomaticInsets,
2272+
headerInset: anchorTopInset,
2273+
topOverlayInset: props.topOverlayInset ?? 0,
2274+
});
22672275

22682276
const theme = useUniwindTheme();
22692277
const iconSubtleColor = theme["--color-icon-subtle"];
@@ -2695,9 +2703,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26952703
presentedFeed,
26962704
props.anchorMessageId,
26972705
(entry) => (entry.type === "message" ? entry.id : null),
2698-
{ anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
2706+
{ anchorOffset: feedTop.anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
26992707
),
2700-
[presentedFeed, props.anchorMessageId, anchorTopInset],
2708+
[presentedFeed, props.anchorMessageId, feedTop.anchorTopInset],
27012709
);
27022710
const failedRunIds = useMemo(
27032711
() => failedFeedRunIds(props.feed, props.latestRun),
@@ -3178,7 +3186,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
31783186
scrollEventThrottle={16}
31793187
ListHeaderComponent={
31803188
<>
3181-
{usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
3189+
{feedTop.spacerHeight > 0 ? (
3190+
<View style={{ height: feedTop.spacerHeight }} />
3191+
) : null}
31823192
{setupAnchorIndex < 0 && props.worktreeSetup ? (
31833193
<WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
31843194
) : null}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
type ContributionStatusEntry,
3+
ProviderDriverKind,
4+
ProviderInstanceId,
5+
ProviderSessionId,
6+
ThreadId,
7+
} from "@t3tools/contracts";
8+
import { describe, expect, it } from "vite-plus/test";
9+
10+
import {
11+
reservesContributionStatusBand,
12+
threadContributionStatusChips,
13+
threadHasContributionStatus,
14+
} from "./thread-contribution-status-presentation";
15+
16+
const entry = (
17+
session: string,
18+
items: ContributionStatusEntry["items"],
19+
driver = "pi",
20+
): ContributionStatusEntry => ({
21+
threadId: ThreadId.make("thread-1"),
22+
source: {
23+
kind: "provider-session",
24+
providerSessionId: ProviderSessionId.make(session),
25+
providerInstanceId: ProviderInstanceId.make(driver),
26+
driver: ProviderDriverKind.make(driver),
27+
},
28+
items,
29+
});
30+
31+
describe("threadContributionStatusChips", () => {
32+
it("shows nothing for a thread without statuses", () => {
33+
expect(threadContributionStatusChips([])).toEqual([]);
34+
});
35+
36+
it("keys chips by source and item key, in the server's order", () => {
37+
const chips = threadContributionStatusChips([
38+
entry("session-a", [
39+
{ key: "mode", text: "● plan" },
40+
{ key: "tokens", text: "12k", tone: "warning", tooltip: "Context is filling up" },
41+
]),
42+
entry("session-b", [{ key: "mode", text: "● plan" }], "codex"),
43+
]);
44+
45+
expect(chips.map((chip) => [chip.text, chip.leadsSource, chip.tone, chip.tooltip])).toEqual([
46+
["● plan", true, "neutral", null],
47+
["12k", false, "warning", "Context is filling up"],
48+
["● plan", true, "neutral", null],
49+
]);
50+
// The same item key under two sources must stay two distinct rows.
51+
expect(new Set(chips.map((chip) => chip.id)).size).toBe(3);
52+
});
53+
54+
it("re-keys a chip when another provider session takes the thread over", () => {
55+
const [before] = threadContributionStatusChips([entry("old", [{ key: "mode", text: "x" }])]);
56+
const [after] = threadContributionStatusChips([entry("new", [{ key: "mode", text: "x" }])]);
57+
58+
expect(after?.id).not.toBe(before?.id);
59+
});
60+
61+
it("attributes the status to its producer without claiming freshness", () => {
62+
const [pi] = threadContributionStatusChips([entry("s", [{ key: "mode", text: "● startup" }])]);
63+
const [codex] = threadContributionStatusChips([
64+
entry("s", [{ key: "mode", text: "busy" }], "codex"),
65+
]);
66+
67+
expect(pi?.accessibilityLabel).toBe("Pi status: ● startup");
68+
expect(pi?.help).toBe("Set by a Pi extension. It can lag a session change.");
69+
expect(codex?.help).toBe("Set by Codex. It can lag a session change.");
70+
});
71+
72+
it("puts the full status text in the details body, where it is never truncated", () => {
73+
const text = "● build — indexing 1,284 files in packages/client-runtime before the next turn";
74+
const [plain, withTooltip] = threadContributionStatusChips([
75+
entry("s", [
76+
{ key: "mode", text },
77+
{ key: "tokens", text: "12k", tooltip: "Context is filling up" },
78+
]),
79+
]);
80+
81+
expect(plain?.details).toEqual({
82+
title: "Pi status",
83+
message: `${text}\n\nSet by a Pi extension. It can lag a session change.`,
84+
});
85+
expect(withTooltip?.details.message).toBe(
86+
"12k\n\nContext is filling up\n\nSet by a Pi extension. It can lag a session change.",
87+
);
88+
});
89+
});
90+
91+
describe("status strip band", () => {
92+
it("counts a thread as having statuses only when an entry has items", () => {
93+
expect(threadHasContributionStatus([])).toBe(false);
94+
expect(threadHasContributionStatus([entry("s", [])])).toBe(false);
95+
expect(threadHasContributionStatus([entry("s", [{ key: "mode", text: "x" }])])).toBe(true);
96+
});
97+
98+
it("keeps a Pi thread's band through set, replace and clear", () => {
99+
const pi = ProviderDriverKind.make("pi");
100+
const band = [false, true, true, false].map((hasStatus) =>
101+
reservesContributionStatusBand({ supported: true, driver: pi, hasStatus }),
102+
);
103+
104+
expect(band).toEqual([true, true, true, true]);
105+
});
106+
107+
it("reserves nothing for other providers without statuses or on servers without the stream", () => {
108+
const codex = ProviderDriverKind.make("codex");
109+
const pi = ProviderDriverKind.make("pi");
110+
111+
expect(
112+
reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: false }),
113+
).toBe(false);
114+
expect(
115+
reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: true }),
116+
).toBe(true);
117+
expect(reservesContributionStatusBand({ supported: false, driver: pi, hasStatus: false })).toBe(
118+
false,
119+
);
120+
});
121+
});

0 commit comments

Comments
 (0)