Files
paseo/packages/app/src/components/agent-stream-view.tsx
T
Mohamed Boudra 81ee887d03 Reduce unnecessary re-renders in agent panel and input components
Narrow zustand selectors to only subscribe to fields each component
actually uses. Split nested objects (availableModes, projectPlacement)
into separate selectors with structural equality checks. Remove the
updatedAt processing-spinner hack in agent-input-area — the server
already guarantees agent status is "running" before ACKing the send
request, so isProcessing can clear on submit success directly.
2026-03-25 13:09:17 +07:00

1200 lines
36 KiB
TypeScript

import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import Animated, {
FadeIn,
FadeOut,
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store";
import {
AssistantMessage,
UserMessage,
ActivityLog,
ToolCall,
TodoListCard,
CompactionMarker,
TurnCopyButton,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
buildAgentStreamRenderModel,
collectAssistantTurnContentForStreamRenderStrategy,
getStreamNeighborItem,
resolveStreamRenderStrategy,
type AgentStreamRenderModel,
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
}
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
agent: AgentScreenAgent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
agentId,
serverId,
agent,
streamItems,
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
onOpenWorkspaceFile,
},
ref,
) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const streamRenderStrategy = useMemo(
() =>
resolveStreamRenderStrategy({
platform: Platform.OS,
isMobileBreakpoint: isMobile,
}),
[isMobile],
);
const [isNearBottom, setIsNearBottom] = useState(true);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(),
);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
const streamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceRoot,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
onOpenWorkspaceFile?.(input);
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations
? undefined
: FadeIn.duration(200);
const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations
? undefined
: FadeOut.duration(200);
useEffect(() => {
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
}, [agentId]);
const handleInlinePathPress = useCallback(
(target: InlinePathTarget) => {
if (!target.path) {
return;
}
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
if (!normalized) {
return;
}
if (normalized.file) {
if (onOpenWorkspaceFile) {
openWorkspaceFile({ filePath: normalized.file });
return;
}
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
return;
}
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
setExplorerTabForCheckout({
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
tab: "files",
});
openFileExplorer();
},
[
agent.cwd,
openFileExplorer,
requestDirectoryListing,
resolvedServerId,
router,
setExplorerTabForCheckout,
openWorkspaceFile,
workspaceId,
],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
tail: streamItems,
head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
useImperativeHandle(
ref,
() => ({
scrollToBottom(reason = "jump-to-bottom") {
viewportRef.current?.scrollToBottom(reason);
},
prepareForViewportChange() {
viewportRef.current?.prepareForViewportChange();
},
}),
[],
);
function scrollToBottom() {
viewportRef.current?.scrollToBottom("jump-to-bottom");
}
const tightGap = theme.spacing[1]; // 4px
const looseGap = theme.spacing[4]; // 16px
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
if (!item || !belowItem) {
return 0;
}
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
return tightGap;
}
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return tightGap;
}
return looseGap;
},
[looseGap, tightGap],
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null,
) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
return;
}
setExpandedInlineToolCallIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(item.id);
} else {
next.delete(item.id);
}
return next;
});
};
switch (item.kind) {
case "user_message": {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isFirstInGroup = aboveItem?.kind !== "user_message";
const isLastInGroup = belowItem?.kind !== "user_message";
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
/>
);
}
case "assistant_message":
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
/>
);
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<ToolCall
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
case "tool_call": {
const { payload } = item;
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
if (payload.source === "agent") {
const data = payload.data;
return (
<ToolCall
toolName={data.name}
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
const data = payload.data;
return (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
case "activity_log":
return (
<ActivityLog
type={item.activityType}
message={item.message}
timestamp={item.timestamp.getTime()}
metadata={item.metadata}
/>
);
case "todo_list":
return <TodoListCard items={item.items} />;
case "compaction":
return <CompactionMarker status={item.status} preTokens={item.preTokens} />;
default:
return null;
}
},
[handleInlinePathPress, agent.cwd, streamRenderStrategy],
);
const renderStreamItem = useCallback(
(
item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null,
) => {
const content = renderStreamItemContent(item, index, items, seamAboveItem);
if (!content) {
return null;
}
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const gapBelow = getGapBetween(item, nextItem ?? null);
const isEndOfAssistantTurn =
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const getTurnContent = () =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy: streamRenderStrategy,
items,
startIndex: index,
});
return (
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
{content}
{isEndOfAssistantTurn ? <TurnCopyButton getContent={getTurnContent} /> : null}
</View>
);
},
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
[pendingPermissions, agentId],
);
const showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
))}
</View>
) : null;
const workingIndicatorNode = showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator />
</View>
) : null;
return {
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetween(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null,
),
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
workingIndicator: workingIndicatorNode,
},
};
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]);
const listEmptyComponent = useMemo(() => {
if (
renderModel.boundary.hasVirtualizedHistory ||
renderModel.boundary.hasMountedHistory ||
renderModel.boundary.hasLiveHead ||
renderModel.auxiliary.pendingPermissions ||
renderModel.auxiliary.workingIndicator
) {
return null;
}
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}, [renderModel]);
const historyItems = renderModel.history;
const liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
historyItems.forEach((item, index) => {
indexById.set(item.id, index);
});
return indexById;
}, [historyItems]);
const renderHistoryRow = useCallback(
(item: StreamItem) => {
const historyIndex = historyIndexById.get(item.id);
if (historyIndex === undefined) {
return null;
}
return renderStreamItem(item, historyIndex, historyItems);
},
[historyIndexById, historyItems, renderStreamItem],
);
const renderHistoryVirtualizedRow = useCallback<
StreamSegmentRenderers["renderHistoryVirtualizedRow"]
>((item) => renderHistoryRow(item), [renderHistoryRow]);
const renderHistoryMountedRow = useCallback<StreamSegmentRenderers["renderHistoryMountedRow"]>(
(item) => renderHistoryRow(item),
[renderHistoryRow],
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
[lastHistoryItem, renderStreamItem],
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
return null;
}
return (
<View style={stylesheet.contentWrapper}>
<View
style={[
stylesheet.listHeaderContent,
boundary.hasLiveHead ? { paddingTop: tightGap } : null,
]}
>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, boundary.hasLiveHead, tightGap]);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
}),
[
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
],
);
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
segments: renderModel.segments,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
})}
</MessageOuterSpacingProvider>
{!isNearBottom && (
<Animated.View
style={stylesheet.scrollToBottomContainer}
entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut}
>
<View style={stylesheet.scrollToBottomInner}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel="Scroll to bottom"
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
</Pressable>
</View>
</Animated.View>
)}
</View>
</ToolCallSheetProvider>
);
},
);
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator() {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(progress);
progress.value = 0;
};
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
return (
<View style={stylesheet.workingIndicatorBubble}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={[stylesheet.workingDot, dotOneStyle]} />
<Animated.View style={[stylesheet.workingDot, dotTwoStyle]} />
<Animated.View style={[stylesheet.workingDot, dotThreeStyle]} />
</View>
</View>
);
}
// Permission Request Card Component
function PermissionRequestCard({
permission,
client,
}: {
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const { request } = permission;
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const description = request.description ?? "";
const planMarkdown = useMemo(() => {
if (!request) {
return undefined;
}
const planFromMetadata =
typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined;
if (planFromMetadata) {
return planFromMetadata;
}
const candidate = request.input?.["plan"];
if (typeof candidate === "string") {
return candidate;
}
return undefined;
}, [request]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}, []);
const permissionMutation = useMutation({
mutationFn: async (input: {
agentId: string;
requestId: string;
response: AgentPermissionResponse;
}) => {
if (!client) {
throw new Error("Daemon client unavailable");
}
return client.respondToPermissionAndWait(
input.agentId,
input.requestId,
input.response,
15000,
);
},
});
const {
reset: resetPermissionMutation,
mutateAsync: respondToPermission,
isPending: isResponding,
} = permissionMutation;
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
useEffect(() => {
resetPermissionMutation();
setRespondingAction(null);
}, [permission.request.id, resetPermissionMutation]);
const handleResponse = useCallback(
(response: AgentPermissionResponse) => {
respondToPermission({
agentId: permission.agentId,
requestId: permission.request.id,
response,
}).catch((error) => {
console.error("[PermissionRequestCard] Failed to respond to permission:", error);
});
},
[permission.agentId, permission.request.id, respondToPermission],
);
if (request.kind === "question") {
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
/>
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
{!isPlanRequest ? (
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
Proposed plan
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
>
How would you like to proceed?
</Text>
<View
style={[
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
]}
>
<Pressable
testID="permission-request-deny"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}}
disabled={isResponding}
>
{respondingAction === "deny" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={permissionStyles.optionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
Deny
</Text>
</View>
)}
</Pressable>
<Pressable
testID="permission-request-accept"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("accept");
handleResponse({ behavior: "allow" });
}}
disabled={isResponding}
>
{respondingAction === "accept" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={permissionStyles.optionContent}>
<Check size={14} color={theme.colors.foreground} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
Accept
</Text>
</View>
)}
</Pressable>
</View>
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
},
listContentContainer: {
paddingVertical: 0,
flexGrow: 1,
paddingHorizontal: {
xs: theme.spacing[2],
md: theme.spacing[4],
},
},
forwardListContentContainer: {
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
list: {
flex: 1,
},
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[12],
},
permissionsContainer: {
gap: theme.spacing[2],
},
listHeaderContent: {
gap: theme.spacing[3],
},
bottomBarWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
paddingLeft: 3,
paddingRight: 3,
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: 0,
paddingVertical: theme.spacing[1],
paddingLeft: theme.spacing[2],
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
borderWidth: 0,
alignSelf: "flex-start",
},
workingDotsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.foregroundMuted,
},
syncingIndicator: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingLeft: theme.spacing[2],
},
syncingIndicatorText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
invertedWrapper: {
transform: [{ scaleY: -1 }],
width: "100%",
},
emptyStateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
scrollToBottomContainer: {
position: "absolute",
bottom: 16,
left: 0,
right: 0,
alignItems: "center",
pointerEvents: "box-none",
},
scrollToBottomInner: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
alignItems: "center",
},
scrollToBottomButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
scrollToBottomIcon: {
color: theme.colors.foreground,
},
}));
const permissionStyles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
section: {
gap: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
},
question: {
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
marginBottom: theme.spacing[1],
},
optionsContainer: {
gap: theme.spacing[2],
},
optionsContainerDesktop: {
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
width: "100%",
},
optionButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
alignItems: "center",
borderWidth: theme.borderWidth[1],
},
optionButtonPressed: {
opacity: 0.9,
},
optionContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
optionText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));