Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1fe8efd
refactor(web): register the Diff side panel
saphid Oct 2, 2026
bcbd324
refactor(web): register the Preview side panel
github-actions[bot] Oct 4, 2026
6befef3
perf(web): keep the panel host stable across chat view renders
github-actions[bot] Oct 7, 2026
1b23a3e
fix(web): send a late annotation only to the thread it was picked in
github-actions[bot] Oct 7, 2026
16877e3
fix(web): lend the annotation sender only after the chat view commits
github-actions[bot] Oct 7, 2026
bb246d6
refactor(web): move the thread terminal panel and drawer out of ChatView
github-actions[bot] Oct 4, 2026
64cdb14
refactor(web): open the right-panel terminal through the panel host
github-actions[bot] Oct 4, 2026
22f34a8
fix(web): keep a local-checkout terminal launch off the thread's work…
github-actions[bot] Oct 6, 2026
e34c8a4
fix(web): keep a terminal the server opened on the checkout off the t…
github-actions[bot] Oct 7, 2026
f8da909
fix(web): keep device results in the thread that started them
github-actions[bot] Oct 4, 2026
aea3b7a
refactor(web): open the Device side panel through the panel host
github-actions[bot] Oct 4, 2026
df78494
refactor(web): open the pull request side panels through the panel host
github-actions[bot] Oct 4, 2026
d1554f5
refactor(web): open the Files side panel through the panel host
github-actions[bot] Oct 4, 2026
7f84c56
fix(web): drop Files actions that settle after leaving the thread
github-actions[bot] Oct 4, 2026
8136ad4
fix(web): open tree clicks in the thread the Files panel shows now
github-actions[bot] Oct 6, 2026
6d65c39
fix(web): keep a Files browser open across composer draft switches
github-actions[bot] Oct 6, 2026
3be1e97
fix(web): point tree clicks at the new thread as soon as it commits
github-actions[bot] Oct 10, 2026
3d2de4d
feat(pi): show extension statuses in the thread header
github-actions[bot] Oct 4, 2026
890b0c5
fix(pi): keep statuses when Pi refuses a rollback fork
github-actions[bot] Oct 6, 2026
ee45b12
feat(mobile): show Pi extension statuses under the thread header
github-actions[bot] Oct 4, 2026
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
120 changes: 120 additions & 0 deletions apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useAtomValue } from "@effect/atom-react";
import type {
ContributionStatusTone,
EnvironmentId,
ProviderDriverKind,
ThreadId,
} from "@t3tools/contracts";
import { useMemo } from "react";
import { Alert, Platform, Pressable, ScrollView, View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { cn } from "../../lib/cn";
import { contributionStatusEnvironment } from "../../state/contribution-status";
import {
reservesContributionStatusBand,
threadContributionStatusChips,
threadHasContributionStatus,
} from "./thread-contribution-status-presentation";

/** The strip's height: one row of chip touch targets (44pt iOS, 48dp Android). */
const STRIP_HEIGHT = Platform.OS === "android" ? 48 : 44;

// Neutral, the default, has no dot: Pi status text often brings its own glyph.
const TONE_DOT_CLASS = {
neutral: null,
info: "bg-adaptive-sky-600-400",
success: "bg-adaptive-emerald-600-400",
warning: "bg-adaptive-amber-700-400",
error: "bg-adaptive-rose-600-400",
} as const satisfies Record<ContributionStatusTone, string | null>;

/**
* The band the feed reserves above its first row for the status strip, passed
* to ThreadFeed's `topOverlayInset`. See `reservesContributionStatusBand`.
*/
export function useThreadContributionStatusStripInset(input: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly supported: boolean;
readonly driver: ProviderDriverKind | undefined;
}): number {
const hasStatus = useAtomValue(
contributionStatusEnvironment.threadStatus(input.environmentId, input.threadId),
threadHasContributionStatus,
);
return reservesContributionStatusBand({ ...input, hasStatus }) ? STRIP_HEIGHT : 0;
}

/**
* Advisory statuses the thread's provider set, such as Pi extension
* `setStatus` text, as one row of chips floating just under the navigation
* header. It overlays the feed like the header does, so a status changing
* never resizes the composer; the feed reserves its band through
* `useThreadContributionStatusStripInset`. Renders nothing when the server
* lacks the capability or the thread has no statuses. Pressing a chip shows
* its tooltip and where it came from.
*/
export function ThreadContributionStatusStrip(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
/** Distance from the screen's top edge to the bottom of the navigation header. */
readonly top: number;
readonly contentMaxWidth: number | undefined;
}) {
const entries = useAtomValue(
contributionStatusEnvironment.threadStatus(props.environmentId, props.threadId),
);
const chips = useMemo(() => threadContributionStatusChips(entries), [entries]);
if (chips.length === 0) return null;

return (
<View
pointerEvents="box-none"
className="absolute inset-x-0"
style={{ top: props.top, height: STRIP_HEIGHT }}
>
<View
pointerEvents="box-none"
className="w-full self-center"
style={{ maxWidth: props.contentMaxWidth }}
>
{/* Sized to its chips, so the feed under the empty rest of the row
still takes touches. */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="max-w-full grow-0 self-start"
contentContainerClassName="px-2.5"
>
{chips.map((chip) => {
const dotClass = TONE_DOT_CLASS[chip.tone];
// The compact pill sits inside a full-size touch target (44pt
// iOS, 48dp Android); neighbours abut without overlapping.
return (
<Pressable
key={chip.id}
accessibilityRole="button"
accessibilityLabel={chip.accessibilityLabel}
accessibilityHint={chip.tooltip ?? chip.help}
className="min-h-[44px] min-w-[44px] items-center justify-center px-[3px] active:opacity-60 android:min-h-[48px] android:min-w-[48px]"
onPress={() => Alert.alert(chip.details.title, chip.details.message)}
>
<View className="min-h-7 flex-row items-center gap-1.5 rounded-full border border-border-subtle bg-card-alt px-2.5">
{chip.leadsSource ? <ProviderIcon provider={chip.driver} size={12} /> : null}
{dotClass === null ? null : (
<View className={cn("h-1.5 w-1.5 shrink-0 rounded-full", dotClass)} />
)}
<Text numberOfLines={1} className="text-xs text-foreground-secondary">
{chip.text}
</Text>
</View>
</Pressable>
);
})}
</ScrollView>
</View>
</View>
);
}
26 changes: 26 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
import { ProviderSubagentBar } from "./ProviderSubagentBar";
import {
ThreadContributionStatusStrip,
useThreadContributionStatusStripInset,
} from "./ThreadContributionStatusStrip";
import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
import {
FLOATING_WORKING_CONTROL_COVERAGE,
Expand Down Expand Up @@ -849,6 +853,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
providerSubagentProvider.models,
)
: null;
const statusStripInset = useThreadContributionStatusStripInset({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
supported: props.serverConfig?.environment.capabilities.contributionStatus === true,
driver: providerSubagentProvider?.driver,
});
const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
(model) => model.slug === providerSubagentModelSlug,
);
Expand Down Expand Up @@ -1172,6 +1182,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
submittedMessageId={submittedMessageId}
contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
contentTopInset={0}
topOverlayInset={statusStripInset}
contentBottomInset={
estimatedOverlayHeight +
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
Expand Down Expand Up @@ -1204,6 +1215,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
) : null}

{showContent ? (
<ThreadContributionStatusStrip
environmentId={props.environmentId}
threadId={props.selectedThread.id}
// A transparent (glass) header overlaps the screen; an opaque one
// ends where the screen begins.
top={
props.usesAutomaticContentInsets === true && Platform.OS === "ios"
? navigationHeaderHeight
: 0
}
contentMaxWidth={contentMaxWidth}
/>
) : null}

{/* Floating composer — sticks to keyboard via KeyboardStickyView */}
{showContent ? (
<KeyboardStickyView
Expand Down
16 changes: 13 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import { cn } from "../../lib/cn";
import {
deriveCenteredContentHorizontalPadding,
deriveThreadFeedInitialContentInset,
deriveThreadFeedTopGeometry,
deriveThreadWorkLogSizing,
type LayoutVariant,
} from "../../lib/layout";
Expand Down Expand Up @@ -295,6 +296,8 @@ export interface ThreadFeedProps {
readonly submittedMessageId: MessageId | null;
readonly contentInsetEndAdjustment: SharedValue<number>;
readonly contentTopInset?: number;
/** Height of UI floating over the feed's top edge below the header (the status strip). */
readonly topOverlayInset?: number;
readonly contentBottomInset?: number;
readonly historyControls?: ThreadFeedHistoryControls;
readonly contentMaxWidth?: number;
Expand Down Expand Up @@ -2306,6 +2309,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const anchorTopInset = usesNativeAutomaticInsets
? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT)
: topContentInset;
const feedTop = deriveThreadFeedTopGeometry({
usesNativeAutomaticInsets,
headerInset: anchorTopInset,
topOverlayInset: props.topOverlayInset ?? 0,
});

const theme = useUniwindTheme();
const iconSubtleColor = theme["--color-icon-subtle"];
Expand Down Expand Up @@ -2753,9 +2761,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
presentedFeed,
props.anchorMessageId,
(entry) => (entry.type === "message" ? entry.id : null),
{ anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
{ anchorOffset: feedTop.anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
),
[presentedFeed, props.anchorMessageId, anchorTopInset],
[presentedFeed, props.anchorMessageId, feedTop.anchorTopInset],
);
const failedRunIds = useMemo(
() => failedFeedRunIds(props.feed, props.latestRun),
Expand Down Expand Up @@ -3252,7 +3260,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
scrollEventThrottle={16}
ListHeaderComponent={
<>
{usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
{feedTop.spacerHeight > 0 ? (
<View style={{ height: feedTop.spacerHeight }} />
) : null}
{setupAnchorIndex < 0 && props.worktreeSetup ? (
<WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
type ContributionStatusEntry,
ProviderDriverKind,
ProviderInstanceId,
ProviderSessionId,
ThreadId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
reservesContributionStatusBand,
threadContributionStatusChips,
threadHasContributionStatus,
} from "./thread-contribution-status-presentation";

const entry = (
session: string,
items: ContributionStatusEntry["items"],
driver = "pi",
): ContributionStatusEntry => ({
threadId: ThreadId.make("thread-1"),
source: {
kind: "provider-session",
providerSessionId: ProviderSessionId.make(session),
providerInstanceId: ProviderInstanceId.make(driver),
driver: ProviderDriverKind.make(driver),
},
items,
});

describe("threadContributionStatusChips", () => {
it("shows nothing for a thread without statuses", () => {
expect(threadContributionStatusChips([])).toEqual([]);
});

it("keys chips by source and item key, in the server's order", () => {
const chips = threadContributionStatusChips([
entry("session-a", [
{ key: "mode", text: "● plan" },
{ key: "tokens", text: "12k", tone: "warning", tooltip: "Context is filling up" },
]),
entry("session-b", [{ key: "mode", text: "● plan" }], "codex"),
]);

expect(chips.map((chip) => [chip.text, chip.leadsSource, chip.tone, chip.tooltip])).toEqual([
["● plan", true, "neutral", null],
["12k", false, "warning", "Context is filling up"],
["● plan", true, "neutral", null],
]);
// The same item key under two sources must stay two distinct rows.
expect(new Set(chips.map((chip) => chip.id)).size).toBe(3);
});

it("re-keys a chip when another provider session takes the thread over", () => {
const [before] = threadContributionStatusChips([entry("old", [{ key: "mode", text: "x" }])]);
const [after] = threadContributionStatusChips([entry("new", [{ key: "mode", text: "x" }])]);

expect(after?.id).not.toBe(before?.id);
});

it("attributes the status to its producer without claiming freshness", () => {
const [pi] = threadContributionStatusChips([entry("s", [{ key: "mode", text: "● startup" }])]);
const [codex] = threadContributionStatusChips([
entry("s", [{ key: "mode", text: "busy" }], "codex"),
]);

expect(pi?.accessibilityLabel).toBe("Pi status: ● startup");
expect(pi?.help).toBe("Set by a Pi extension. It can lag a session change.");
expect(codex?.help).toBe("Set by Codex. It can lag a session change.");
});

it("puts the full status text in the details body, where it is never truncated", () => {
const text = "● build — indexing 1,284 files in packages/client-runtime before the next turn";
const [plain, withTooltip] = threadContributionStatusChips([
entry("s", [
{ key: "mode", text },
{ key: "tokens", text: "12k", tooltip: "Context is filling up" },
]),
]);

expect(plain?.details).toEqual({
title: "Pi status",
message: `${text}\n\nSet by a Pi extension. It can lag a session change.`,
});
expect(withTooltip?.details.message).toBe(
"12k\n\nContext is filling up\n\nSet by a Pi extension. It can lag a session change.",
);
});
});

describe("status strip band", () => {
it("counts a thread as having statuses only when an entry has items", () => {
expect(threadHasContributionStatus([])).toBe(false);
expect(threadHasContributionStatus([entry("s", [])])).toBe(false);
expect(threadHasContributionStatus([entry("s", [{ key: "mode", text: "x" }])])).toBe(true);
});

it("keeps a Pi thread's band through set, replace and clear", () => {
const pi = ProviderDriverKind.make("pi");
const band = [false, true, true, false].map((hasStatus) =>
reservesContributionStatusBand({ supported: true, driver: pi, hasStatus }),
);

expect(band).toEqual([true, true, true, true]);
});

it("reserves nothing for other providers without statuses or on servers without the stream", () => {
const codex = ProviderDriverKind.make("codex");
const pi = ProviderDriverKind.make("pi");

expect(
reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: false }),
).toBe(false);
expect(
reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: true }),
).toBe(true);
expect(reservesContributionStatusBand({ supported: false, driver: pi, hasStatus: false })).toBe(
false,
);
});
});
Loading
Loading