Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
</SettingsSection>
<SettingsSection title="Project environment">
<FanoutSwitchRow
icon="hammer"
label="Load direnv environment"
subtitle="Start agents with the project's allowed .envrc, such as a Nix dev shell."
value={uniform("enableDirenvEnvironment")}
disabled={disabledFor("enableDirenvEnvironment")}
onValueChange={(value) => write({ enableDirenvEnvironment: value })}
/>
</SettingsSection>
</>
) : null}

Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,7 @@ function renderFeedEntry(
props: Pick<
ThreadFeedProps,
| "environmentId"
| "threadId"
| "onUseArtifactTemplate"
| "skills"
| "dispatchingMessageId"
Expand Down Expand Up @@ -1755,6 +1756,7 @@ function renderFeedEntry(
key={`${entry.id}:${props.workRowSizing.textSizeKey}`}
activities={entry.activities}
environmentId={props.environmentId}
threadId={props.threadId}
anchorKey={entry.id}
copiedRowId={props.copiedRowId}
expandedRows={props.expandedWorkRows}
Expand Down Expand Up @@ -2755,6 +2757,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
<ThreadMediaVisibility>
{renderFeedEntry(info, {
environmentId: props.environmentId,
threadId: props.threadId,
dispatchingMessageId: props.dispatchingMessageId,
onEditPendingMessage: props.onEditPendingMessage,
copiedRowId,
Expand Down Expand Up @@ -2829,6 +2832,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleWorkGroup,
onToggleWorkRow,
props.environmentId,
props.threadId,
props.onUseArtifactTemplate,
props.skills,
renderMarkdownImage,
Expand Down
63 changes: 62 additions & 1 deletion apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "react";
import {
AccessibilityInfo,
Alert,
AppState,
type ColorValue,
Pressable,
Expand All @@ -32,7 +33,7 @@ import {
View,
} from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts";
import type { EnvironmentId, ThreadId, ToolActivityIcon } from "@t3tools/contracts";
import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";

import { AppText as Text } from "../../components/AppText";
Expand Down Expand Up @@ -71,6 +72,8 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useAssetUrl } from "../../state/assets";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";

const SHIMMER_WIDTH = 72;
const SHIMMER_SWEEP_MS = 1_350;
Expand Down Expand Up @@ -416,6 +419,7 @@ interface ThreadWorkLogProps {
readonly activities: ReadonlyArray<ThreadFeedActivity>;
readonly anchorKey: string;
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly copiedRowId: string | null;
readonly expandedRows: Readonly<Record<string, boolean>>;
readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
Expand All @@ -439,6 +443,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
copied={props.copiedRowId === row.id}
expanded={props.expandedRows[row.id] ?? false}
environmentId={props.environmentId}
threadId={props.threadId}
iconSubtleColor={props.iconSubtleColor}
onCopyRow={props.onCopyRow}
onToggleRow={props.onToggleRow}
Expand All @@ -451,6 +456,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
props.copiedRowId,
props.expandedRows,
props.environmentId,
props.threadId,
props.iconSubtleColor,
props.onCopyRow,
props.onToggleRow,
Expand Down Expand Up @@ -730,6 +736,54 @@ function workLogRowKey(row: ThreadFeedActivity): string {
return row.id;
}

/** Allows the thread's blocked `.envrc`; the next message loads it. */
// The work log remounts rows, so the outcome outlives the button.
const allowedDirenvWarnings = new Set<string>();

function AllowDirenvButton(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly warningId: string;
}) {
const allowDirenv = useAtomCommand(threadEnvironment.allowDirenv, "allow direnv");
const [state, setState] = useState<"idle" | "pending" | "allowed">(() =>
allowedDirenvWarnings.has(props.warningId) ? "allowed" : "idle",
);
return (
<Pressable
accessibilityRole="button"
accessibilityHint="Loads the project's direnv environment with your next message."
disabled={state !== "idle"}
hitSlop={6}
onPress={async () => {
setState("pending");
const result = await allowDirenv({
environmentId: props.environmentId,
input: { threadId: props.threadId },
});
if (result._tag === "Success" && result.value.allowed) {
allowedDirenvWarnings.add(props.warningId);
setState("allowed");
return;
}
setState("idle");
if (result._tag === "Success") {
Alert.alert("Could not allow the .envrc", result.value.error);
}
}}
className="min-h-8 justify-center px-2"
>
<Text className="font-t3-medium text-xs text-foreground">
{state === "allowed"
? "Allowed · applies to your next message"
: state === "pending"
? "Allowing…"
: "Allow .envrc"}
</Text>
</Pressable>
);
}

const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
props: Omit<
ThreadWorkLogProps,
Expand Down Expand Up @@ -846,6 +900,13 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
)}

<View className="shrink-0 flex-row items-center gap-px">
{row.workEntry.warningAction?.type === "direnv.allow" ? (
<AllowDirenvButton
environmentId={props.environmentId}
threadId={props.threadId}
warningId={row.workEntry.id}
/>
) : null}
{props.copied ? (
<Text className="pr-1 font-t3-medium text-3xs text-adaptive-emerald-600-400">
Copied
Expand Down
51 changes: 51 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3748,3 +3748,54 @@ it("keeps attachment-only question answers expandable outside mobile work groups
expect(running[1]).toBe(group);
expect(running[2]?.type).toBe("work-toggle");
});

it("keeps a warning with a button visible beside the tools around it", () => {
const turnId = TurnId.make("turn-direnv");
const thread = makeThread({
id: ThreadId.make("thread-direnv"),
projectId: ProjectId.make("project-direnv"),
title: "Blocked .envrc",
latestTurn: {
turnId,
state: "completed",
requestedAt: "2026-09-08T00:00:00.000Z",
startedAt: "2026-09-08T00:00:00.000Z",
completedAt: "2026-09-08T00:00:04.000Z",
assistantMessageId: null,
},
activities: [
makeActivity({
id: EventId.make("direnv-warning"),
createdAt: "2026-09-08T00:00:01.000Z",
kind: "runtime.warning",
summary: "The project's .envrc is blocked.",
turnId,
payload: {
message: "The project's .envrc is blocked.",
action: { type: "direnv.allow" },
},
}),
makeActivity({
id: EventId.make("tool-after-warning"),
createdAt: "2026-09-08T00:00:02.000Z",
kind: "tool.completed",
tone: "tool",
summary: "Read files",
turnId,
payload: { itemType: "command_execution", status: "completed" },
}),
],
});

const feed = buildThreadFeed(thread);
const warning = feed.find(
(entry) =>
entry.type === "activity-group" &&
entry.activities.some((activity) => activity.workEntry.warningAction !== undefined),
);
expect(warning).toMatchObject({
activities: [{ workEntry: { warningAction: { type: "direnv.allow" } } }],
});
const presented = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
expect(presented).toContain(warning);
});
27 changes: 21 additions & 6 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
OrchestrationLatestTurn,
OrchestrationThread,
OrchestrationThreadActivity,
RuntimeWarningAction,
ToolLifecycleItemType,
TurnId,
UserInputQuestion,
Expand All @@ -30,6 +31,7 @@ import {
workEntryIndicatesToolFailure,
workEntryIndicatesToolSuccess,
workLogEntryIsToolLike,
readRuntimeWarningAction,
type ToolGroupSummaryKind,
type WorkLogToolLifecycleStatus,
} from "@t3tools/client-runtime/work-log/presentation";
Expand Down Expand Up @@ -101,6 +103,8 @@ export interface WorkLogEntry {
requestKind?: PendingApproval["requestKind"];
toolLifecycleStatus?: WorkLogToolLifecycleStatus;
sourceActivityKind?: OrchestrationThreadActivity["kind"];
/** Button a runtime warning offers, such as allowing a blocked `.envrc`. */
warningAction?: RuntimeWarningAction;
toolCallId?: string;
/**
* One row per workflow run or per-turn batch of direct spawns, like web's
Expand Down Expand Up @@ -279,8 +283,16 @@ export function isContextCompactionActivityGroup(
);
}

function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
/**
* Groups that render as their own rows instead of folding behind a toggle:
* answered questions, and warnings offering a button the user needs to see.
*/
function isInlineActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return entry.activities.some(
(activity) =>
activity.workEntry.questionAnswer !== undefined ||
activity.workEntry.warningAction !== undefined,
);
}

function normalizeDraftAnswer(value: string | undefined): string | null {
Expand Down Expand Up @@ -574,6 +586,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const message = asTrimmedString(payload?.message);
if (message) entry.detail = message;
}
const warningAction = readRuntimeWarningAction(activity.kind, payload);
if (warningAction) entry.warningAction = warningAction;
if (viewedImagePath) {
entry.viewedImagePath = viewedImagePath;
}
Expand Down Expand Up @@ -1584,7 +1598,8 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th

const isStandalone =
entry.activity.workEntry.sourceActivityKind === "context-compaction" ||
entry.activity.workEntry.questionAnswer !== undefined;
entry.activity.workEntry.questionAnswer !== undefined ||
entry.activity.workEntry.warningAction !== undefined;
if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) {
flushGroup();
}
Expand Down Expand Up @@ -1711,7 +1726,7 @@ function deriveThreadFeedTurnFolds(
(entry) =>
entry.id !== firstAssistantMessageId &&
entry.id !== terminalAssistantMessageId &&
!(entry.type === "activity-group" && isUserInputActivityGroup(entry)),
!(entry.type === "activity-group" && isInlineActivityGroup(entry)),
)
.map((entry) => entry.id),
);
Expand Down Expand Up @@ -1902,7 +1917,7 @@ function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null {
if (
entry.type === "activity-group" &&
!isContextCompactionActivityGroup(entry) &&
!isUserInputActivityGroup(entry) &&
!isInlineActivityGroup(entry) &&
entry.activities.every(
(activity) => !activity.workEntry.agentSpawn && activity.workEntry.tone !== "error",
)
Expand Down Expand Up @@ -2069,7 +2084,7 @@ function appendPresentedFeedEntry(
result.push(entry);
return;
}
if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) {
if (isContextCompactionActivityGroup(entry) || isInlineActivityGroup(entry)) {
result.push(entry);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ const startupDependencies = Layer.mergeAll(
getInstanceInfo: () => Effect.die("unused"),
rollbackConversation: () => Effect.die("unused"),
uploadFeedback: () => Effect.die("unused"),
refreshProjectEnvironment: () => Effect.succeed(false),
streamEvents: Stream.empty,
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
[WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
[WS_METHODS.projectEnvironmentAllowDirenv]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
[WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
[WS_METHODS.vcsPull]: AuthOrchestrationOperateScope,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ function createProviderServiceHarness(
}),
rollbackConversation,
uploadFeedback: () => unsupported(),
refreshProjectEnvironment: () => Effect.succeed(false),
get streamEvents() {
return Stream.fromPubSub(runtimeEventPubSub);
},
Expand Down
Loading
Loading