Files
paseo/packages/app/src/agent-stream/view.tsx
T
Mohamed Boudra cc744cc669 feat(app): let users set the chat and Markdown content width (#5680)
* feat(app): let users set the chat and Markdown content width

Wide and ultrawide screens were stuck with an 820px reading column. The
width is now an Appearance setting that flows through the Unistyles theme,
like the font sizes, so every content surface picks it up.

The stored value stays null until the user picks a width, so a future
change to the default reaches everyone who never customized it. Reset
writes null back.

The native composer dock keeps plain React Native styles and takes the width
inline because those styles land on Reanimated views, where theme-driven
Unistyles crash on theme changes.

* fix(app): pass the content width into the web stream viewport

The viewport read app settings itself, which required a QueryClient in every
render of the stream strategy. The agent stream view already reads settings,
so it hands the width over with the rest of the render input.
2026-09-29 13:49:03 +01:00

1789 lines
57 KiB
TypeScript

import { ChatFind, ChatFindExpansion } from "@/agent-stream/chat-find";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
Pressable,
Platform,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openExplorerSidebarView } from "@/workspace-tabs/explorer-sidebar";
import {
AssistantMessage,
SpeakMessage,
UserMessage,
Notification,
ToolCall,
TodoListCard,
CompactionMarker,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingMessageSubmission } from "@/composer/submission/model";
import type { TurnPresentation } from "@/timeline/turn-liveness";
import type { PendingPermission } from "@/types/shared";
import type {
AgentCapabilityFlags,
AgentPermissionAction,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { resolveContentMaxWidth, useSettings } from "@/hooks/use-settings";
import type { ToastApi } from "@/components/toast-host";
import { returnToTimelineTail } from "./timeline-tail-navigation";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail";
import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import {
CompletedTurnFooterRow,
TurnFooter,
TURN_FOOTER_BOTTOM_SPACING,
type AssistantTurnForkHandler,
type InFlightTurnForkHandler,
type TurnContentStrategy,
} from "./turn-footer";
import { resolveBottomOverlayTailInset } from "./bottom-overlay-inset";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useForkAgent } from "@/hooks/use-fork-agent";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useStreamHistoryWindow } from "./use-stream-history-window";
import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
bottomOverlayInset: number;
}): ReactNode {
if (!input.pendingPermissions && !input.turnFooter && input.bottomOverlayInset === 0) {
return null;
}
return (
<>
{input.turnFooter}
{input.pendingPermissions ? (
<View style={stylesheet.contentWrapper}>
<View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View>
</View>
) : null}
{input.bottomOverlayInset > 0 ? (
<BottomOverlayInset height={input.bottomOverlayInset} />
) : null}
</>
);
}
function BottomOverlayInset({ height }: { height: number }) {
const style = useMemo(() => ({ height }), [height]);
return <View style={style} />;
}
function renderPendingPermissionsNode(input: {
pendingPermissions: PendingPermission[];
client: DaemonClient | null;
}): ReactNode {
if (input.pendingPermissions.length === 0) {
return null;
}
return (
<View style={stylesheet.permissionsContainer}>
{input.pendingPermissions.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={input.client} />
))}
</View>
);
}
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
strategy: TurnContentStrategy;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
}): ReactNode {
if (!input.content) {
return null;
}
const footerHost = input.layoutItem.completedFooter;
const footer = footerHost ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
supportsTimelineCursor={input.supportsTimelineCursor}
onForkAssistantTurn={input.onForkAssistantTurn}
/>
) : null;
const content = (
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
{input.content}
</StreamItemWrapper>
);
if (input.layoutItem.frameOrder === "footer-then-content") {
return (
<>
{footer}
{content}
</>
);
}
return (
<>
{content}
{footer}
</>
);
}
function renderListEmptyComponent(input: {
renderModel: AgentStreamRenderModel;
emptyStateStyle: StyleProp<ViewStyle>;
emptyText: string;
}): ReactNode {
if (
input.renderModel.boundary.hasVirtualizedHistory ||
input.renderModel.boundary.hasMountedHistory ||
input.renderModel.boundary.hasLiveHead ||
input.renderModel.auxiliary.pendingPermissions ||
input.renderModel.auxiliary.turnFooter
) {
return null;
}
return (
<View style={input.emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>{input.emptyText}</Text>
</View>
);
}
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
// what stops that churn: a row renders again only when its stream item identity, its layout item
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
const HistoryStreamRow = memo(function HistoryStreamRow({
layoutItem,
renderStreamItem,
}: {
item: StreamItem;
layoutItem: StreamLayoutItem;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}) {
return <>{renderStreamItem(layoutItem)}</>;
});
function renderHistoryStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return (
<HistoryStreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
/>
);
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
}
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
}
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
context: AgentScreenAgent;
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
pendingMessageSubmissions?: readonly PendingMessageSubmission[];
turnPresentation: TurnPresentation;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
bottomOverlayControlClearance?: number;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean;
historyPagination?: {
hasOlder: boolean;
isLoadingOlder: boolean;
progressKey: string | null;
onLoadOlder: () => boolean | Promise<boolean>;
};
}
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
"supportsStreaming",
"supportsSessionPersistence",
"supportsDynamicModes",
"supportsMcpServers",
"supportsReasoningStream",
"supportsToolInvocations",
"supportsRewindConversation",
"supportsRewindFiles",
"supportsRewindBoth",
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
function useRetainedValue<T>(value: T, active: boolean): T {
const retainedRef = useRef(value);
if (active) {
retainedRef.current = value;
}
return active ? value : retainedRef.current;
}
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function resolveBottomOverlayControlOffset(clearance: number | undefined): number {
return Math.max(16, clearance ?? 0);
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
agentId,
serverId,
context,
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS,
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
onOpenWorkspaceFile,
readOnly = false,
historyPagination,
},
ref,
) {
const { t } = useTranslation();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
[pendingMessageSubmissions],
);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
() =>
resolveStreamRenderStrategy({
platform: Platform.OS,
isMobileBreakpoint: isMobile,
}),
[isMobile],
);
const [isNearBottom, setIsNearBottom] = useState(true);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(),
);
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(),
);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? context.serverId ?? "";
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
const sessionStreamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
);
const streamHead = providedStreamHead ?? sessionStreamHead;
const forkAgent = useForkAgent({ serverId: resolvedServerId, toast, readOnly });
const supportsAgentForkContextCursor = useSessionStore(
(state) =>
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
);
const supportsChatOutline = useSessionStore(
(state) =>
state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true,
);
const timelineEpoch = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null,
);
const isTimelineDetached = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true,
);
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
workspaceRoot,
});
const agentHistoryPagination = useLoadOlderAgentHistory({
serverId: resolvedServerId,
agentId,
toast,
});
const {
isLoadingOlder: remoteIsLoadingOlder,
hasOlder: remoteHasOlder,
progressKey: remoteProgressKey,
loadOlder: loadRemoteOlder,
} = historyPagination
? {
isLoadingOlder: historyPagination.isLoadingOlder,
hasOlder: historyPagination.hasOlder,
progressKey: historyPagination.progressKey,
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
// 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());
setExpandedToolCallGroupIds(new Set());
}, [agentId]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) {
return;
}
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) {
return;
}
if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({
location,
disposition,
});
return;
}
if (context.workspaceId) {
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
return;
}
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
},
);
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "preferred");
});
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
async ({ target, boundary }) => {
await forkAgent({
agentId,
agent: context,
workspaceId: context.workspaceId,
target,
boundary,
});
},
);
// The in-flight turn forks with no boundary at all: `selectForkContextRows`
// projects the whole timeline when neither boundary field is given, so the
// fork carries everything up to now, including the response still streaming
// in front of the user.
const handleForkInFlightTurn: InFlightTurnForkHandler = useStableEvent(async (target) => {
await forkAgent({
agentId,
agent: context,
workspaceId: context.workspaceId,
target,
});
});
// Freeze stream presentation while this tab slot is hidden to prevent offscreen
// cell-window and turn-lifecycle renders from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useRetainedPanelActive();
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
const isTurnActive = effectiveTurnPresentation.isActive;
const presentStream = useMemo(() => createStreamPresentation(), []);
const presentation = useMemo(
() =>
presentStream({
tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
transform: transformTimelineItem,
level: toolCallDetailLevel,
isTurnActive,
}),
[
presentStream,
effectiveStreamItems,
effectiveStreamHead,
transformTimelineItem,
toolCallDetailLevel,
isTurnActive,
],
);
const {
start: historyWindowStart,
hasLocalHistory,
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: presentation.tail,
loadRemoteOlder,
});
const isLoadingOlder = remoteIsLoadingOlder;
const hasOlder = hasLocalHistory || remoteHasOlder;
const progressKey = `${remoteProgressKey ?? "local"}:${historyWindowStart}`;
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
isTurnActive,
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
tail: presentation.tail,
head: presentation.head,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
historyStart: historyWindowStart,
});
}, [
isMobile,
isTurnActive,
presentation.head,
presentation.tail,
effectiveTurnPresentation.startedAt,
historyWindowStart,
]);
const streamLayout = useMemo(
() =>
layoutStream({
strategy: streamRenderStrategy,
isTurnActive,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
isTurnActive,
streamRenderStrategy,
],
);
const handleTimelineHistoryLoadError = useCallback(() => {
toast?.error(t("agentStream.historyLoadFailed"));
}, [t, toast]);
// Chat find and the chat outline address messages, and an assistant message is a
// group of block rows, so this is a set of message ids and never of row ids.
const visibleMessageIds = useMemo(
() =>
new Set(
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
getStreamItemMessageId,
),
),
[baseRenderModel.history, baseRenderModel.segments.liveHead],
);
const chatOutline = useChatOutline({
agentId,
serverId: resolvedServerId,
timelineEpoch,
tail: effectiveStreamItems,
head: effectiveStreamHead,
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
visibleMessageIds,
revealLoadedMessage: revealLoadedHistory,
});
useImperativeHandle(
ref,
() => ({
scrollToBottom(reason = "jump-to-bottom") {
viewportRef.current?.scrollToBottom(reason);
},
prepareForViewportChange() {
viewportRef.current?.prepareForViewportChange();
},
}),
[],
);
const scrollToBottom = useCallback(() => {
if (!isTimelineDetached) {
viewportRef.current?.scrollToBottom("jump-to-bottom");
return;
}
void returnToTimelineTail({
fetchTail: () =>
getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, {
...planTimelineTailFetch(),
}),
scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"),
onError: handleTimelineHistoryLoadError,
});
}, [agentId, handleTimelineHistoryLoadError, isTimelineDetached, resolvedServerId]);
const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
return;
}
setExpandedInlineToolCallIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
},
[streamRenderStrategy],
);
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
setExpandedToolCallGroupIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(groupId);
} else {
next.delete(groupId);
}
return next;
});
}, []);
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.messageId}
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
capabilities={context.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
isPending={
item.clientMessageId !== undefined &&
pendingClientMessageIds.has(item.clientMessageId)
}
/>
);
},
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "assistant_message" }>) => {
return (
<AssistantFileLinkResolverProvider
client={client}
serverId={resolvedServerId}
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast}
>
<ChatFindExpansion messageId={getStreamItemMessageId(item)}>
{(renderFullContent) => (
<AssistantMessage
renderFullContent={renderFullContent}
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
phase={layoutItem.phase}
/>
)}
</ChatFindExpansion>
</AssistantFileLinkResolverProvider>
);
},
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
return (
<ThoughtSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
text={item.text}
status={item.status}
isLastInSequence={layoutItem.isLastInToolSequence}
defaultExpanded={autoExpandReasoning}
/>
);
},
[autoExpandReasoning, setInlineDetailsExpanded],
);
const renderSingleToolCallItem = useCallback(
(
item: Extract<StreamItem, { kind: "tool_call" }>,
isLastInSequence: boolean,
maxDetailHeight?: number,
) => {
const { payload } = item;
if (payload.source === "agent") {
const data = payload.data;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return (
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
);
}
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.name}
error={data.error}
status={data.status}
detail={data.detail}
cwd={context.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
maxDetailHeight={maxDetailHeight}
/>
);
}
const data = payload.data;
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
maxDetailHeight={maxDetailHeight}
/>
);
},
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
const getToolCallGroup = useStableEvent((hostId: string) =>
presentation.groupsByHostId.get(hostId),
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const group = getToolCallGroup(item.id);
if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
const expanded = expandedToolCallGroupIds.has(group.run.id);
return (
<OverviewToolCallGroupView
group={group}
expanded={expanded}
isLastInSequence={layoutItem.isLastInToolSequence}
onExpandedChange={setToolCallGroupExpanded}
>
{expanded
? group.run.calls.map((call, index) => (
<React.Fragment key={call.id}>
{renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)}
</React.Fragment>
))
: null}
</OverviewToolCallGroupView>
);
},
[
expandedToolCallGroupIds,
getToolCallGroup,
renderSingleToolCallItem,
setToolCallGroupExpanded,
],
);
const renderStreamItemContent = useCallback(
(layoutItem: StreamLayoutItem) => {
const item = layoutItem.item;
switch (item.kind) {
case "user_message":
return renderUserMessageItem(layoutItem, item);
case "assistant_message":
return renderAssistantMessageItem(layoutItem, item);
case "thought":
return renderThoughtItem(layoutItem, item);
case "tool_call":
return renderToolCallItem(layoutItem, item);
case "notification":
return <Notification level={item.level} message={item.message} />;
case "todo_list":
return <TodoListCard items={item.items} activity={item.activity} />;
case "compaction":
return (
<CompactionMarker
status={item.status}
trigger={item.trigger}
preTokens={item.preTokens}
/>
);
case "plugin":
return (
<PluginTimelineItemView agentId={agentId} item={item} serverId={resolvedServerId} />
);
default:
return null;
}
},
[
agentId,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
resolvedServerId,
],
);
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
const renderStreamItem = useCallback(
(layoutItem: StreamLayoutItem) => {
const content = renderStreamItemContent(layoutItem);
return renderStreamItemWithTurnFooter({
content,
layoutItem,
strategy: streamRenderStrategy,
supportsTimelineCursor: supportsAgentForkContextCursor,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
});
},
[
handleForkAssistantTurn,
readOnly,
renderStreamItemContent,
streamRenderStrategy,
supportsAgentForkContextCursor,
],
);
const pendingPermissionItems = useMemo(
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
[pendingPermissions, agentId],
);
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
pendingPermissions: pendingPermissionItems,
client,
}),
[client, pendingPermissionItems],
);
const turnFooterNode = useMemo(
() =>
isTurnActive || bottomTurnFooterHost ? (
<TurnFooter
isRunning={isTurnActive}
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
supportsTimelineCursor={supportsAgentForkContextCursor}
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
onForkInFlightTurn={readOnly ? undefined : handleForkInFlightTurn}
/>
) : null,
[
handleForkAssistantTurn,
handleForkInFlightTurn,
readOnly,
isTurnActive,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
streamRenderStrategy,
supportsAgentForkContextCursor,
],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
...baseRenderModel,
boundary: baseRenderModel.boundary,
auxiliary: {
pendingPermissions: pendingPermissionsNode,
turnFooter: turnFooterNode,
},
};
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const scrollToBottomContainerStyle = useMemo(
() => [
stylesheet.scrollToBottomContainer,
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
],
[bottomOverlayControlClearance],
);
const listEmptyComponent = useMemo(
() =>
renderListEmptyComponent({
renderModel,
emptyStateStyle,
emptyText: t("agentStream.empty"),
}),
[renderModel, emptyStateStyle, t],
);
const { boundary, auxiliary } = renderModel;
const layoutHistoryItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.history) {
itemById.set(item.item.id, item);
}
return itemById;
}, [streamLayout.history]);
const layoutLiveHeadItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.liveHead) {
itemById.set(item.item.id, item);
}
return itemById;
}, [streamLayout.liveHead]);
const handleReadingPositionChange = useStableEvent((rowId: string | null) => {
const row =
rowId === null
? undefined
: (layoutHistoryItemById.get(rowId) ?? layoutLiveHeadItemById.get(rowId));
chatOutline.reportReadingPosition(row?.item.timelineCursor?.seq ?? null);
});
const renderHistoryRow = useCallback(
(item: StreamItem) =>
renderHistoryStreamItem({
item,
layoutItemById: layoutHistoryItemById,
renderStreamItem,
}),
[layoutHistoryItemById, renderStreamItem],
);
const renderHistoryVirtualizedRow = useCallback<
StreamSegmentRenderers["renderHistoryVirtualizedRow"]
>((item) => renderHistoryRow(item), [renderHistoryRow]);
const renderHistoryMountedRow = useCallback<StreamSegmentRenderers["renderHistoryMountedRow"]>(
(item) => renderHistoryRow(item),
[renderHistoryRow],
);
// useStableEvent keeps the function reference stable across flushes.
// layoutLiveHeadItemById and renderStreamItem are read from the ref at call time,
// so the live-head render always uses the latest layout without causing renderers
// to be a new object on every text-chunk flush.
const renderLiveHeadRow: StreamSegmentRenderers["renderLiveHeadRow"] = useStableEvent(
(item: StreamItem) =>
renderLiveHeadStreamItem({
item,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
}),
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
const existingTailSpacing =
auxiliary.turnFooter && !auxiliary.pendingPermissions ? TURN_FOOTER_BOTTOM_SPACING : 0;
const bottomOverlayInset = resolveBottomOverlayTailInset({
requiredTailClearance: bottomOverlayTailClearance,
existingTailSpacing,
});
return renderLiveAuxiliaryNode({
pendingPermissions: auxiliary.pendingPermissions,
turnFooter: auxiliary.turnFooter,
bottomOverlayInset,
});
}, [auxiliary.pendingPermissions, auxiliary.turnFooter, bottomOverlayTailClearance]);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
}),
[
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
],
);
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const historyRowRevision = useMemo(
() => ({
contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: expandedToolCallGroupIds,
globalDisplayState: isMobile,
}),
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
);
const findItems = useMemo(
() => [...effectiveStreamItems, ...(effectiveStreamHead ?? [])],
[effectiveStreamItems, effectiveStreamHead],
);
return (
<ChatFind
agentId={agentId}
serverId={resolvedServerId}
epoch={timelineEpoch}
items={findItems}
viewportRef={viewportRef}
revealLoadedMessage={revealLoadedHistory}
visibleMessageIds={visibleMessageIds}
>
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroupIds,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
prompts={chatOutline.prompts}
activePrompt={chatOutline.activePrompt}
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
</Pressable>
</Animated.View>
</View>
)}
</AssistantSelectionCopySurface>
</ToolCallSheetProvider>
</ChatFind>
);
},
);
function agentCapabilityFlagsEqual(
left: AgentCapabilityFlags | undefined,
right: AgentCapabilityFlags | undefined,
): boolean {
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
}
function collectAgentProjectPlacementDiffs(
left: AgentScreenAgent["projectPlacement"],
right: AgentScreenAgent["projectPlacement"],
): string[] {
const reasons: string[] = [];
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
if (left?.projectName !== right?.projectName) {
reasons.push("agent.projectPlacement.projectName");
}
if (left?.projectKey !== right?.projectKey) {
reasons.push("agent.projectPlacement.projectKey");
}
return reasons;
}
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.provider !== right.provider) reasons.push("agent.provider");
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
if (left.model !== right.model) reasons.push("agent.model");
if (left.thinkingOptionId !== right.thinkingOptionId) {
reasons.push("agent.thinkingOptionId");
}
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
reasons.push("agent.runtimeInfo.modeId");
}
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
reasons.push("agent.runtimeInfo.model");
}
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.features !== right.features) reasons.push("agent.features");
return reasons;
}
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
if (left.id !== right.id) reasons.push("agent.id");
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
if (left.status !== right.status) reasons.push("agent.status");
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
reasons.push("agent.capabilities");
}
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
reasons.push(...collectAgentSetupDiffs(left, right));
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
return reasons;
}
function bottomAnchorRouteRequestsEqual(
left: BottomAnchorRouteRequest | null | undefined,
right: BottomAnchorRouteRequest | null | undefined,
): boolean {
return (
left?.agentId === right?.agentId &&
left?.reason === right?.reason &&
left?.requestKey === right?.requestKey
);
}
function historyPaginationPropsEqual(
left: AgentStreamViewProps["historyPagination"],
right: AgentStreamViewProps["historyPagination"],
): boolean {
return (
left?.hasOlder === right?.hasOlder &&
left?.isLoadingOlder === right?.isLoadingOlder &&
left?.progressKey === right?.progressKey &&
left?.onLoadOlder === right?.onLoadOlder
);
}
function agentStreamViewPropsEqual(
left: AgentStreamViewProps,
right: AgentStreamViewProps,
): boolean {
const reasons: string[] = [];
if (left.agentId !== right.agentId) reasons.push("agentId");
if (left.serverId !== right.serverId) reasons.push("serverId");
reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context));
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) {
reasons.push("pendingMessageSubmissions");
}
if (left.turnPresentation !== right.turnPresentation) reasons.push("turnPresentation");
if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
) {
reasons.push("routeBottomAnchorRequest");
}
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) {
reasons.push("historyPagination");
}
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
return reasons.length === 0;
}
export const AgentStreamView = memo(AgentStreamViewComponent, agentStreamViewPropsEqual);
AgentStreamView.displayName = "AgentStreamView";
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
> {
itemId: string;
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
}
interface ThoughtSlotProps {
itemId: string;
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
text: string;
status: Extract<StreamItem, { kind: "thought" }>["status"];
isLastInSequence: boolean;
defaultExpanded: boolean;
}
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
function ThoughtSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
text,
status,
isLastInSequence,
defaultExpanded,
}: ThoughtSlotProps) {
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
return (
<ToolCallSlot
itemId={itemId}
onInlineDetailsExpandedChangeByItemId={onInlineDetailsExpandedChangeByItemId}
toolName="thinking"
args={revealedText}
status={status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
defaultExpanded={defaultExpanded}
forceInline={defaultExpanded}
/>
);
}
function ToolCallSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
...rest
}: ToolCallSlotProps) {
const handleExpandedChange = useCallback(
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
[onInlineDetailsExpandedChangeByItemId, itemId],
);
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedCheckIcon = withUnistyles(Check);
const ThemedXIcon = withUnistyles(X);
const primaryColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const mutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const pressableStyle = ({
pressed,
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
hovered ? permissionStyles.optionButtonHovered : null,
pressed ? permissionStyles.optionButtonPressed : null,
];
interface PermissionActionButtonProps {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
isPrimary: boolean;
Icon: typeof ThemedCheckIcon;
testID: string;
onPress: (action: AgentPermissionAction) => void;
}
function PermissionActionButton({
action,
isRespondingAction,
isResponding,
isPrimary,
Icon,
testID,
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const optionTextStyle = isPrimary
? [permissionStyles.optionText, permissionStyles.optionTextPrimary]
: permissionStyles.optionText;
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
return (
<Pressable
accessibilityRole="button"
testID={testID}
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
>
{isRespondingAction ? (
<ThemedLoadingSpinner size="small" uniProps={colorMapping} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} uniProps={colorMapping} />
<Text style={optionTextStyle}>{action.label}</Text>
</View>
)}
</Pressable>
);
}
function PermissionRequestCard({
permission,
client,
}: {
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const { request } = permission;
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest
? t("agentStream.permission.plan")
: (request.title ?? request.name ?? t("agentStream.permission.required"));
const description = request.description ?? "";
const resolvedToolCallDetail = useMemo(
() =>
request.detail ?? {
type: "unknown" as const,
input: request.input ?? null,
output: null,
},
[request.detail, request.input],
);
const resolvedActions = useMemo((): AgentPermissionAction[] => {
if (request.kind === "question") {
return [];
}
if (Array.isArray(request.actions) && request.actions.length > 0) {
return request.actions;
}
return [
{
id: "reject",
label: t("agentStream.permission.deny"),
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "accept",
label: isPlanRequest
? t("agentStream.permission.implement")
: t("agentStream.permission.accept"),
behavior: "allow",
variant: "primary",
},
];
}, [isPlanRequest, request, t]);
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 permissionMutation = useMutation({
mutationFn: async (input: {
agentId: string;
requestId: string;
response: AgentPermissionResponse;
}) => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return client.respondToPermissionAndWait(
input.agentId,
input.requestId,
input.response,
15000,
);
},
});
const {
reset: resetPermissionMutation,
mutateAsync: respondToPermission,
isPending: isResponding,
} = permissionMutation;
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
useEffect(() => {
resetPermissionMutation();
setRespondingActionId(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],
);
const handleActionPress = useCallback(
(action: AgentPermissionAction) => {
setRespondingActionId(action.id);
if (action.behavior === "allow") {
handleResponse({
behavior: "allow",
selectedActionId: action.id,
});
return;
}
handleResponse({
behavior: "deny",
selectedActionId: action.id,
message: "Denied by user",
});
},
[handleResponse],
);
const optionsContainerStyle = useMemo(
() => [
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
],
[isMobile],
);
if (request.kind === "question") {
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
/>
);
}
const footer = (
<>
<Text testID="permission-request-question" style={permissionStyles.question}>
{t("agentStream.permission.question")}
</Text>
<View style={optionsContainerStyle}>
{resolvedActions.map((action) => {
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon;
let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement")
testID = "permission-request-accept";
else testID = `permission-request-action-${action.id}`;
return (
<PermissionActionButton
key={action.id}
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
isPrimary={isPrimary}
Icon={Icon}
testID={testID}
onPress={handleActionPress}
/>
);
})}
</View>
</>
);
if (isPlanRequest && planMarkdown) {
return (
<PlanCard
title={title}
description={description}
text={planMarkdown}
outcome="pending"
footer={footer}
testID="permission-plan-card"
disableOuterSpacing
/>
);
}
return (
<View style={permissionStyles.container}>
<Text style={permissionStyles.title}>{title}</Text>
{description ? <Text style={permissionStyles.description}>{description}</Text> : null}
{planMarkdown ? (
<PlanCard
title={t("agentStream.permission.proposedPlan")}
text={planMarkdown}
testID="permission-plan-card"
disableOuterSpacing
/>
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent detail={resolvedToolCallDetail} maxHeight={200} />
) : null}
{footer}
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentWrapper: {
width: "100%",
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
listContentContainer: {
paddingVertical: 0,
flexGrow: 1,
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[4],
},
},
forwardListContentContainer: {
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
list: {
flex: 1,
},
streamItemWrapper: {
width: "100%",
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[12],
},
permissionsContainer: {
gap: theme.spacing[2],
},
listHeaderContent: {
gap: theme.spacing[3],
},
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.base,
},
invertedWrapper: {
transform: [{ scaleY: -1 }],
width: "100%",
},
emptyStateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
scrollToBottomContainer: {
position: "absolute",
left: 0,
right: 0,
alignItems: "center",
},
scrollToBottomButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
...theme.shadow.sm,
},
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],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
color: theme.colors.foreground,
},
description: {
fontSize: theme.fontSize.base,
lineHeight: 20,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
},
question: {
fontSize: theme.fontSize.base,
marginTop: theme.spacing[1],
marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted,
},
optionsContainer: {
gap: theme.spacing[2],
},
optionsContainerDesktop: {
flexDirection: "row",
flexWrap: "wrap",
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],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
optionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
optionButtonPressed: {
opacity: 0.9,
},
optionContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
optionText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
optionTextPrimary: {
color: theme.colors.foreground,
},
}));
interface StreamItemWrapperProps {
itemId: string;
gapBelow: number;
children: ReactNode;
}
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
const wrapperStyle = useMemo(
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
[gapBelow],
);
return <View style={wrapperStyle}>{children}</View>;
}