Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 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
a9c53af
feat: offer plugin actions in the palette, slash menu and thread menus
github-actions[bot] Oct 7, 2026
bbfea1b
fix(contracts): keep a plugin action when only its placement is unknown
github-actions[bot] Oct 6, 2026
9192987
fix: match plugin action names in the slash menu regardless of case
github-actions[bot] Oct 6, 2026
2103ba6
docs(plugins): say the slash menu opens only at the start of a line
github-actions[bot] Oct 7, 2026
19a7f90
fix(web,mobile): offer plugin actions only to connections that can ru…
github-actions[bot] Oct 7, 2026
047b5aa
test(web): stub plugin actions in the thread action menu hook test
github-actions[bot] Oct 7, 2026
cc348fb
fix(web,mobile): check the live permission when a plugin action runs
github-actions[bot] Oct 7, 2026
f38868a
docs(plugins): say plugins run under the server's account
github-actions[bot] Oct 7, 2026
5e1ce8d
fix(mobile): offer environment plugin actions in the palette without …
github-actions[bot] Oct 7, 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
32 changes: 31 additions & 1 deletion apps/mobile/src/features/keyboard/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useNavigation } from "@react-navigation/native";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
import { AuthOrchestrationOperateScope, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Expand All @@ -25,13 +25,16 @@ import { cn } from "../../lib/cn";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
import { useProjects, useThreadShell, useThreadShells } from "../../state/entities";
import { runPluginAction, usePluginActions } from "../../state/plugin-actions";
import { useThreadSearch } from "../../state/queries";
import { useEnvironmentScope } from "../../state/session";
import { useWorkspaceEnvironments } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
import {
buildPluginActionPaletteItems,
filterCommandPaletteItems,
nextPaletteIndex,
type CommandPaletteItem,
Expand Down Expand Up @@ -66,6 +69,7 @@ const ACTION_ICONS: Record<string, AppSymbolName> = {
function itemIcon(item: CommandPaletteItem): AppSymbolName {
if (item.kind === "project") return "folder";
if (item.kind === "thread") return "text.bubble";
if (item.key.startsWith("plugin-action:")) return "cube";
return ACTION_ICONS[item.key] ?? "ellipsis";
}

Expand Down Expand Up @@ -146,6 +150,17 @@ export function CommandPalette(props: {
const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]);
const activeThread = useThreadShell(activeThreadRef);
const environments = useWorkspaceEnvironments();
// Plugin actions belong to the open thread's environment, else the first connected one.
const pluginActionEnvironmentId =
activeThreadRef?.environmentId ??
environments.find((environment) => environment.connectionState === "connected")
?.environmentId ??
null;
const pluginActions = usePluginActions(pluginActionEnvironmentId);
const canRunPluginActions = useEnvironmentScope(
pluginActionEnvironmentId,
AuthOrchestrationOperateScope,
);
const { savedConnectionsById } = useSavedRemoteConnections();
const [query, setQuery] = useState("");
const [selection, setSelection] = useState<string | null>(null);
Expand Down Expand Up @@ -300,6 +315,18 @@ export function CommandPalette(props: {
})),
);
}
if (pluginActionEnvironmentId !== null) {
actions.push(
...buildPluginActionPaletteItems({
actions: pluginActions,
canOperate: canRunPluginActions,
environmentId: pluginActionEnvironmentId,
threadId: activeThread?.id ?? null,
projectId: activeThread?.projectId ?? null,
runAction: (input) => void runPluginAction(input),
}),
);
}
const projectItems: CommandPaletteItem[] = projects.map((project) => ({
key: `project:${scopedProjectKey(project.environmentId, project.id)}`,
kind: "project",
Expand Down Expand Up @@ -346,6 +373,9 @@ export function CommandPalette(props: {
activeThread,
activeThreadRef,
navigation,
canRunPluginActions,
pluginActionEnvironmentId,
pluginActions,
projects,
runCommand,
savedConnectionsById,
Expand Down
86 changes: 85 additions & 1 deletion apps/mobile/src/features/keyboard/commandPaletteItems.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vite-plus/test";
import {
EnvironmentId,
PluginActionId,
ProjectId,
ThreadId,
type PluginAction,
} from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";

import {
buildPluginActionPaletteItems,
filterCommandPaletteItems,
nextPaletteIndex,
type CommandPaletteItem,
Expand Down Expand Up @@ -74,3 +82,79 @@ describe("nextPaletteIndex", () => {
expect(nextPaletteIndex(0, 1, 0)).toBe(0);
});
});

describe("buildPluginActionPaletteItems", () => {
const environmentId = EnvironmentId.make("environment-1");
const thread = {
environmentId,
threadId: ThreadId.make("thread-1"),
projectId: ProjectId.make("project-1"),
};
const deploy: PluginAction = {
id: PluginActionId.make("installation-1:1:deploy"),
pluginId: "acme.deploy",
pluginName: "Deploy",
name: "deploy",
title: "Deploy this branch",
target: "thread",
placements: ["command-palette"],
};
const refresh: PluginAction = {
...deploy,
id: PluginActionId.make("installation-1:1:refresh"),
name: "refresh",
title: "Refresh caches",
target: "environment",
};

it("runs an offered action in the open thread's environment", () => {
const runAction = vi.fn();
const offered = buildPluginActionPaletteItems({
actions: [deploy],
canOperate: true,
...thread,
runAction,
});
expect(offered.map((item) => item.title)).toEqual(["Deploy this branch"]);

offered[0]?.run();

expect(runAction).toHaveBeenCalledWith({
environmentId: thread.environmentId,
action: deploy,
target: { _tag: "thread", threadId: thread.threadId },
});
});

it("offers environment actions when no thread is open", () => {
const runAction = vi.fn();
const offered = buildPluginActionPaletteItems({
actions: [deploy, refresh],
canOperate: true,
environmentId,
threadId: null,
projectId: null,
runAction,
});
expect(offered.map((item) => item.title)).toEqual(["Refresh caches"]);

offered[0]?.run();

expect(runAction).toHaveBeenCalledWith({
environmentId,
action: refresh,
target: { _tag: "environment" },
});
});

it("offers nothing to a connection that cannot operate the environment", () => {
expect(
buildPluginActionPaletteItems({
actions: [deploy],
canOperate: false,
...thread,
runAction: vi.fn(),
}),
).toEqual([]);
});
});
43 changes: 43 additions & 0 deletions apps/mobile/src/features/keyboard/commandPaletteItems.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions";
import type {
EnvironmentId,
PluginAction,
PluginActionTarget,
ProjectId,
ThreadId,
} from "@t3tools/contracts";

export interface CommandPaletteItem {
readonly key: string;
readonly kind: "action" | "project" | "thread";
Expand Down Expand Up @@ -44,3 +53,37 @@ export function filterCommandPaletteItems(
export function nextPaletteIndex(index: number, direction: -1 | 1, count: number) {
return count === 0 ? 0 : (index + direction + count) % count;
}

/**
* The palette plugin actions of one environment, for the open thread and its
* project when there is one. Running one needs `orchestration:operate`, so a
* connection without it is offered none.
*/
export function buildPluginActionPaletteItems(input: {
readonly actions: ReadonlyArray<PluginAction>;
readonly canOperate: boolean;
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId | null;
readonly projectId: ProjectId | null;
readonly runAction: (input: {
readonly environmentId: EnvironmentId;
readonly action: PluginAction;
readonly target: PluginActionTarget;
}) => void;
}): CommandPaletteItem[] {
if (!input.canOperate) return [];
const { environmentId } = input;
const entries = pluginActionsAt(input.actions, "command-palette", {
threadId: input.threadId,
projectId: input.projectId,
});
const labels = pluginActionLabels(entries.map((entry) => entry.action));
return entries.map(({ action, target }, index) => ({
key: `plugin-action:${action.id}`,
kind: "action",
title: labels[index] ?? action.title,
detail: action.description ?? action.pluginName,
searchTerms: [action.name, action.pluginName, "plugin"],
run: () => input.runAction({ environmentId, action, target }),
}));
}
12 changes: 12 additions & 0 deletions apps/mobile/src/features/threads/ComposerCommandPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
type ProviderSkillSourceKind,
} from "@t3tools/client-runtime/providerSkills";
import type {
PluginAction,
PluginActionTarget,
PullRequestContextMetadata,
ScopedThreadRef,
ServerProviderSkill,
Expand Down Expand Up @@ -59,6 +61,14 @@ export type ComposerCommandItem =
readonly skill: ServerProviderSkill;
readonly label: string;
readonly description: string;
}
| {
readonly id: string;
readonly type: "plugin-action";
readonly action: PluginAction;
readonly target: PluginActionTarget;
readonly label: string;
readonly description: string;
};

interface ComposerCommandPopoverProps {
Expand Down Expand Up @@ -109,6 +119,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
return null;
case "thread":
return "text.bubble";
case "plugin-action":
return "cube";
}
}

Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,8 @@ export function NewTaskDraftScreen(props: {
draftMessage: flow.prompt,
ownerKey: flow.draftKey,
environmentId: selectedProject?.environmentId ?? null,
// Project actions run on the selected project; a draft has no thread yet.
projectId: selectedProject?.id ?? null,
threadShells: useThreadShells(),
pullRequestProjectId: selectedEnvironmentServerConfig?.environment.capabilities.pullRequests
? (selectedProject?.id ?? null)
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
environmentId: props.environmentId,
threadShells: useThreadShells(),
currentThreadId: props.selectedThread.id,
projectId: props.selectedThread.projectId,
projectCwd: props.projectCwd,
pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests
? (project?.id ?? null)
Expand Down
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>
);
}
Loading
Loading