Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 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
d4e14af
feat(server): run consented local plugins in supervised child processes
github-actions[bot] Oct 7, 2026
967fa41
fix(server): answer every plugin result with a message the server can…
github-actions[bot] Oct 6, 2026
c98af8e
fix(server): drop what a plugin registered before its activation failed
github-actions[bot] Oct 6, 2026
0ebc19d
fix(server): exit a plugin child on a corrupt IPC line and cover its …
github-actions[bot] Oct 7, 2026
f732076
fix(server): hold a plugin's partial IPC line in one growing buffer
github-actions[bot] Oct 7, 2026
8031b1c
fix(server): hold plugin calls made during startup until enabled plug…
github-actions[bot] Oct 7, 2026
d099322
fix(server): count a plugin IPC line's newline against the read budget
github-actions[bot] Oct 7, 2026
b0e67fd
fix(server): restore enabled plugins before any management step at st…
github-actions[bot] Oct 10, 2026
af2e2f4
fix(server): let calls waiting on a plugin start hear how it ended
github-actions[bot] Oct 10, 2026
7861dd7
fix(server): refuse a plugin file swapped for a FIFO while digesting
github-actions[bot] Oct 10, 2026
b8617ec
chore(server): say why plugin host modules import Node builtins
github-actions[bot] Oct 10, 2026
1bc3012
fix(server): answer a plugin call cancelled before its handler started
github-actions[bot] Oct 10, 2026
c9f3193
test(server): start the cooperative handler before timing it out
github-actions[bot] Oct 10, 2026
21b8a3d
fix(server): close a dead plugin's stderr that a process it started h…
github-actions[bot] Oct 10, 2026
d5a0db8
test(server): answer a replayed request only once its card is projected
github-actions[bot] Oct 10, 2026
aefe6e5
feat(server): deliver finished runs to plugins from an acknowledged c…
github-actions[bot] Oct 4, 2026
a2c51b2
fix(server): let a run's queued checkpoint capture decide its finaliz…
github-actions[bot] Oct 6, 2026
c94efe0
fix(server): refuse the events capability without the proposed API op…
github-actions[bot] Oct 6, 2026
8f86852
fix(server): drop a plugin's event handlers when its activation fails
github-actions[bot] Oct 7, 2026
3fa503e
test(server): cover finalization of a run stopped through the ownersh…
github-actions[bot] Oct 7, 2026
6c9333a
feat(server): let agents list and call plugin tools through MCP
github-actions[bot] Oct 7, 2026
06e4fcb
fix(server): list no plugins to MCP callers outside a T3 thread
github-actions[bot] Oct 7, 2026
ff7f887
fix(server): drop a reused credential's reservation when its grant up…
github-actions[bot] Oct 7, 2026
93566f6
docs(plugins): say plugins run under the server's account
github-actions[bot] Oct 7, 2026
d57a1d6
fix(server): drop a reused credential's reservation when its reuse ch…
github-actions[bot] Oct 7, 2026
ce235d8
feat(server): typed plugin settings, write-only secrets and plugin st…
github-actions[bot] Oct 7, 2026
12983f3
docs(plugins): say plugins run under the server's account
github-actions[bot] Oct 7, 2026
dfb6a92
fix(server): refresh open plugin settings when the declared fields ch…
github-actions[bot] Oct 10, 2026
34383e7
fix(server): finish a plugin's host work before disable returns
github-actions[bot] Oct 10, 2026
910a13b
fix(server): wake host calls waiting on a plugin below the stream buffer
github-actions[bot] Oct 10, 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