Add command autocomplete and stream spacing updates
This commit is contained in:
@@ -30,6 +30,8 @@ import {
|
||||
} from "./message-input";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { Theme } from "@/styles/theme";
|
||||
import { CommandAutocomplete } from "./command-autocomplete";
|
||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -127,6 +129,33 @@ export function AgentInputArea({
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
||||
|
||||
// Command autocomplete logic
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
|
||||
|
||||
// Prefetch commands when agent is available (not on new agent screen)
|
||||
const isRealAgent = agentId && !agentId.startsWith("__");
|
||||
const { commands } = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: !!isRealAgent && !!serverId,
|
||||
});
|
||||
|
||||
// Filter commands for keyboard navigation
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!showCommandAutocomplete) return [];
|
||||
const filterLower = commandFilter.toLowerCase();
|
||||
return commands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(filterLower)
|
||||
);
|
||||
}, [commands, commandFilter, showCommandAutocomplete]);
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setCommandSelectedIndex(0);
|
||||
}, [commandFilter]);
|
||||
|
||||
const { pickImages } = useImageAttachmentPicker();
|
||||
const agentIdRef = useRef(agentId);
|
||||
@@ -369,6 +398,64 @@ export function AgentInputArea({
|
||||
queueMessage(payload.text, payload.images);
|
||||
}, []);
|
||||
|
||||
// Handle command selection from autocomplete
|
||||
const handleCommandSelect = useCallback(
|
||||
(cmd: { name: string; description: string; argumentHint: string }) => {
|
||||
setUserInput(`/${cmd.name} `);
|
||||
messageInputRef.current?.focus();
|
||||
},
|
||||
[setUserInput]
|
||||
);
|
||||
|
||||
// Handle keyboard navigation for command autocomplete
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: { key: string; preventDefault: () => void }) => {
|
||||
if (!showCommandAutocomplete || filteredCommands.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setCommandSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredCommands.length - 1
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setCommandSelectedIndex((prev) =>
|
||||
prev < filteredCommands.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selected = filteredCommands[commandSelectedIndex];
|
||||
if (selected) {
|
||||
handleCommandSelect(selected);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setUserInput("");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[
|
||||
showCommandAutocomplete,
|
||||
filteredCommands,
|
||||
commandSelectedIndex,
|
||||
handleCommandSelect,
|
||||
setUserInput,
|
||||
]
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<>
|
||||
<Pressable
|
||||
@@ -472,6 +559,17 @@ export function AgentInputArea({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Command autocomplete dropdown */}
|
||||
{showCommandAutocomplete && isRealAgent && (
|
||||
<CommandAutocomplete
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
filter={commandFilter}
|
||||
selectedIndex={commandSelectedIndex}
|
||||
onSelect={handleCommandSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
@@ -492,6 +590,7 @@ export function AgentInputArea({
|
||||
rightContent={rightContent}
|
||||
isAgentRunning={isAgentRunning}
|
||||
onQueue={handleQueue}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ToolCall,
|
||||
AgentThoughtMessage,
|
||||
TodoListCard,
|
||||
MessageOuterSpacingProvider,
|
||||
type InlinePathTarget,
|
||||
} from "./message";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
@@ -55,6 +56,10 @@ import {
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought";
|
||||
type PermissionResolvedMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent_permission_resolved" }
|
||||
@@ -75,6 +80,7 @@ export function AgentStreamView({
|
||||
pendingPermissions,
|
||||
}: AgentStreamViewProps) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const hasScrolledInitially = useRef(false);
|
||||
@@ -257,43 +263,90 @@ export function AgentStreamView({
|
||||
setIsNearBottom(true);
|
||||
}
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
({ item }: ListRenderItemInfo<StreamItem>) => {
|
||||
let content: React.ReactNode = null;
|
||||
const flatListData = useMemo(() => {
|
||||
return [...streamItems].reverse();
|
||||
}, [streamItems]);
|
||||
|
||||
const tightGap = theme.spacing[1]; // 4px
|
||||
const looseGap = theme.spacing[4]; // 16px
|
||||
|
||||
// In inverted lists, marginBottom renders as visual gap ABOVE the item.
|
||||
// Index 0 is visually at the bottom, so use the item ABOVE (index + 1) to compute spacing.
|
||||
const getGapAbove = useCallback(
|
||||
(item: StreamItem, index: number) => {
|
||||
const aboveItem = flatListData[index + 1];
|
||||
if (!aboveItem) {
|
||||
// This is the topmost item; nothing above it visually.
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Same type groups get tight gap (4px)
|
||||
if (isUserMessageItem(item) && isUserMessageItem(aboveItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(aboveItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Different types get loose gap (16px)
|
||||
return looseGap;
|
||||
},
|
||||
[flatListData, looseGap, tightGap]
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
(item: StreamItem, index: number) => {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
content = (
|
||||
case "user_message": {
|
||||
// In inverted list: index+1 is the item above, index-1 is below.
|
||||
const prevItem = flatListData[index + 1];
|
||||
const nextItem = flatListData[index - 1];
|
||||
const isFirstInGroup = prevItem?.kind !== "user_message";
|
||||
const isLastInGroup = nextItem?.kind !== "user_message";
|
||||
return (
|
||||
<UserMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
isFirstInGroup={isFirstInGroup}
|
||||
isLastInGroup={isLastInGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "assistant_message":
|
||||
content = (
|
||||
return (
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "thought":
|
||||
console.log("[AgentStreamView] renderStreamItem", { item });
|
||||
content = (
|
||||
<AgentThoughtMessage message={item.text} status={item.status} />
|
||||
case "thought": {
|
||||
// In inverted list: index+1 is the item above, index-1 is below.
|
||||
const nextItem = flatListData[index - 1];
|
||||
const isLastInSequence =
|
||||
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||
return (
|
||||
<AgentThoughtMessage
|
||||
message={item.text}
|
||||
status={item.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_call": {
|
||||
const { payload } = item;
|
||||
// In inverted list: index+1 is the item above, index-1 is below.
|
||||
const nextItem = flatListData[index - 1];
|
||||
const isLastInSequence =
|
||||
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
content = (
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.name}
|
||||
args={data.input}
|
||||
@@ -301,24 +354,25 @@ export function AgentStreamView({
|
||||
error={data.error}
|
||||
status={data.status as "executing" | "completed" | "failed"}
|
||||
cwd={agent.cwd}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const data = payload.data;
|
||||
content = (
|
||||
<ToolCall
|
||||
toolName={data.toolName}
|
||||
args={data.arguments}
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
const data = payload.data;
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.toolName}
|
||||
args={data.arguments}
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "activity_log":
|
||||
content = (
|
||||
return (
|
||||
<ActivityLog
|
||||
type={item.activityType}
|
||||
message={item.message}
|
||||
@@ -326,29 +380,44 @@ export function AgentStreamView({
|
||||
metadata={item.metadata}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "todo_list":
|
||||
content = (
|
||||
return (
|
||||
<TodoListCard
|
||||
provider={item.provider}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
items={item.items}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
content = null;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[handleInlinePathPress, agent.cwd, flatListData]
|
||||
);
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||
const content = renderStreamItemContent(item, index);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <View style={stylesheet.streamItemWrapper}>{content}</View>;
|
||||
const gap = getGapAbove(item, index);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
stylesheet.streamItemWrapper,
|
||||
gap ? { marginBottom: gap } : null,
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleInlinePathPress, agent.cwd]
|
||||
[getGapAbove, renderStreamItemContent]
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -391,18 +460,12 @@ export function AgentStreamView({
|
||||
) : null}
|
||||
|
||||
{hasHeadItems
|
||||
? [...streamHead].reverse().map((item) => {
|
||||
const rendered = renderStreamItem({
|
||||
item,
|
||||
index: 0,
|
||||
separators: {
|
||||
highlight: () => {},
|
||||
unhighlight: () => {},
|
||||
updateProps: () => {},
|
||||
},
|
||||
});
|
||||
? [...streamHead].reverse().map((item, index) => {
|
||||
const rendered = renderStreamItemContent(item, index);
|
||||
return rendered ? (
|
||||
<View key={item.id}>{rendered}</View>
|
||||
<View key={item.id} style={stylesheet.streamItemWrapper}>
|
||||
{rendered}
|
||||
</View>
|
||||
) : null;
|
||||
})
|
||||
: null}
|
||||
@@ -414,13 +477,9 @@ export function AgentStreamView({
|
||||
showWorkingIndicator,
|
||||
wsOrInert,
|
||||
streamHead,
|
||||
renderStreamItem,
|
||||
renderStreamItemContent,
|
||||
]);
|
||||
|
||||
const flatListData = useMemo(() => {
|
||||
return [...streamItems].reverse();
|
||||
}, [streamItems]);
|
||||
|
||||
const flatListExtraData = useMemo(
|
||||
() => ({
|
||||
pendingPermissionCount: pendingPermissionItems.length,
|
||||
@@ -432,39 +491,41 @@ export function AgentStreamView({
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
<View style={stylesheet.container}>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={flatListData}
|
||||
renderItem={renderStreamItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: 0,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
style={stylesheet.list}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEnd}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 40,
|
||||
}}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
inverted
|
||||
/>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={flatListData}
|
||||
renderItem={renderStreamItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: 0,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
style={stylesheet.list}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEnd}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 40,
|
||||
}}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
inverted
|
||||
/>
|
||||
</MessageOuterSpacingProvider>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!isNearBottom && (
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { Theme } from "@/styles/theme";
|
||||
|
||||
interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
}
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
filter: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (command: AgentSlashCommand) => void;
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
serverId,
|
||||
agentId,
|
||||
filter,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: CommandAutocompleteProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Filter commands based on input after /
|
||||
const filterLower = filter.toLowerCase();
|
||||
const filteredCommands = commands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(filterLower)
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.loadingItem}>
|
||||
<Text style={styles.loadingText}>Loading commands...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>Error: {error?.message ?? "Failed to load"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredCommands.length === 0) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>No commands found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="always">
|
||||
{filteredCommands.map((cmd, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<Pressable
|
||||
key={cmd.name}
|
||||
onPress={() => onSelect(cmd)}
|
||||
style={[
|
||||
styles.commandItem,
|
||||
isSelected && {
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.commandHeader}>
|
||||
<Text style={styles.commandName}>/{cmd.name}</Text>
|
||||
{cmd.argumentHint && (
|
||||
<Text style={styles.commandArgs}>{cmd.argumentHint}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.commandDescription} numberOfLines={1}>
|
||||
{cmd.description}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: string) {
|
||||
const filterLower = filter.toLowerCase();
|
||||
return commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.card,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
maxHeight: 200,
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
commandItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
commandHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
commandName: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
commandArgs: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
commandDescription: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
loadingItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
loadingText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
emptyItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
})) as any) as Record<string, any>;
|
||||
@@ -64,6 +64,8 @@ export interface MessageInputProps {
|
||||
isAgentRunning?: boolean;
|
||||
/** Callback for queue button when agent is running */
|
||||
onQueue?: (payload: MessagePayload) => void;
|
||||
/** Intercept key press events before default handling. Return true to prevent default. */
|
||||
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||
}
|
||||
|
||||
export interface MessageInputRef {
|
||||
@@ -110,6 +112,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
rightContent,
|
||||
isAgentRunning = false,
|
||||
onQueue,
|
||||
onKeyPress: onKeyPressCallback,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -383,6 +386,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
if (!shouldHandleDesktopSubmit) return;
|
||||
|
||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||
if (onKeyPressCallback) {
|
||||
const handled = onKeyPressCallback({
|
||||
key: event.nativeEvent.key,
|
||||
preventDefault: () => event.preventDefault(),
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
// Cmd+B or Ctrl+B: toggle sidebar
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { View, Text, Pressable, Animated } from "react-native";
|
||||
import { useState, useEffect, useRef, memo, useMemo, useCallback } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
memo,
|
||||
useMemo,
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
@@ -46,23 +55,53 @@ import {
|
||||
interface UserMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
isFirstInGroup?: boolean;
|
||||
isLastInGroup?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
const MessageOuterSpacingContext = createContext(false);
|
||||
|
||||
export function MessageOuterSpacingProvider({
|
||||
disableOuterSpacing,
|
||||
children,
|
||||
}: {
|
||||
disableOuterSpacing: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MessageOuterSpacingContext.Provider value={disableOuterSpacing}>
|
||||
{children}
|
||||
</MessageOuterSpacingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||||
const contextValue = useContext(MessageOuterSpacingContext);
|
||||
return disableOuterSpacing ?? contextValue;
|
||||
}
|
||||
|
||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: theme.spacing[4],
|
||||
marginTop: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
containerFirstInGroup: {
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
containerLastInGroup: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
bubble: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius.sm,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
maxWidth: "80%",
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -90,7 +129,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
message,
|
||||
timestamp,
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -121,7 +165,19 @@ export const UserMessage = memo(function UserMessage({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={userMessageStylesheet.container}>
|
||||
<View
|
||||
style={[
|
||||
userMessageStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
userMessageStylesheet.containerSpacing,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
isFirstInGroup &&
|
||||
userMessageStylesheet.containerFirstInGroup,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
isLastInGroup &&
|
||||
userMessageStylesheet.containerLastInGroup,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Copy message"
|
||||
@@ -157,14 +213,17 @@ interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginBottom: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
// Used in custom markdownRules for inline code styling
|
||||
markdownCodeInline: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
@@ -194,7 +253,12 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
containerLastInSequence: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
pressable: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -347,8 +411,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp,
|
||||
onInlinePathPress,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const lastPathRef = useRef<string | null>(null);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
@@ -499,7 +566,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
}, [onInlinePathPress]);
|
||||
|
||||
return (
|
||||
<View style={assistantMessageStylesheet.container}>
|
||||
<View
|
||||
style={[
|
||||
assistantMessageStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
assistantMessageStylesheet.containerSpacing,
|
||||
]}
|
||||
>
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{message}
|
||||
</Markdown>
|
||||
@@ -516,15 +589,18 @@ interface ActivityLogProps {
|
||||
artifactType?: string;
|
||||
title?: string;
|
||||
onArtifactClick?: (artifactId: string) => void;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginBottom: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
overflow: "hidden",
|
||||
},
|
||||
pressableSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
pressableActive: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
@@ -597,7 +673,10 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
artifactType,
|
||||
title,
|
||||
onArtifactClick,
|
||||
disableOuterSpacing,
|
||||
}: ActivityLogProps) {
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const typeConfig = {
|
||||
@@ -648,6 +727,8 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
disabled={!isInteractive}
|
||||
style={[
|
||||
activityLogStylesheet.pressable,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
activityLogStylesheet.pressableSpacing,
|
||||
config.bg,
|
||||
isInteractive && activityLogStylesheet.pressableActive,
|
||||
]}
|
||||
@@ -694,6 +775,7 @@ interface TodoListCardProps {
|
||||
provider: AgentProvider;
|
||||
timestamp: number;
|
||||
items: TodoEntry[];
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
function formatPlanTimestamp(timestamp: number): string {
|
||||
@@ -711,6 +793,8 @@ function formatPlanTimestamp(timestamp: number): string {
|
||||
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
card: {
|
||||
@@ -799,7 +883,10 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
provider,
|
||||
timestamp,
|
||||
items,
|
||||
disableOuterSpacing,
|
||||
}: TodoListCardProps) {
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const providerLabel = useMemo(() => {
|
||||
const definition = getAgentProviderDefinition(provider);
|
||||
return definition?.label ?? provider;
|
||||
@@ -818,7 +905,13 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
const iconColor = theme.colors.background;
|
||||
|
||||
return (
|
||||
<View style={todoListCardStylesheet.container}>
|
||||
<View
|
||||
style={[
|
||||
todoListCardStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
todoListCardStylesheet.containerSpacing,
|
||||
]}
|
||||
>
|
||||
<View style={todoListCardStylesheet.card}>
|
||||
<View style={todoListCardStylesheet.header}>
|
||||
<View style={todoListCardStylesheet.headerMeta}>
|
||||
@@ -877,6 +970,8 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
interface AgentThoughtMessageProps {
|
||||
message: string;
|
||||
status?: ThoughtStatus;
|
||||
isLastInSequence?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
interface ExpandableBadgeProps {
|
||||
@@ -888,6 +983,8 @@ interface ExpandableBadgeProps {
|
||||
renderDetails?: () => ReactNode;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
isLastInSequence?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
const ExpandableBadge = memo(function ExpandableBadge({
|
||||
@@ -899,8 +996,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
renderDetails,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
isLastInSequence = false,
|
||||
disableOuterSpacing,
|
||||
}: ExpandableBadgeProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasDetails = Boolean(renderDetails);
|
||||
const detailContent = hasDetails && isExpanded ? renderDetails?.() : null;
|
||||
|
||||
@@ -956,7 +1057,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={expandableBadgeStylesheet.container}>
|
||||
<View
|
||||
style={[
|
||||
expandableBadgeStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
expandableBadgeStylesheet.containerSpacing,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
isLastInSequence &&
|
||||
expandableBadgeStylesheet.containerLastInSequence,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
onPress={hasDetails ? onToggle : undefined}
|
||||
disabled={!hasDetails}
|
||||
@@ -1016,6 +1126,8 @@ const agentThoughtStylesheet = StyleSheet.create((theme) => ({
|
||||
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||
message,
|
||||
status = "ready",
|
||||
isLastInSequence = false,
|
||||
disableOuterSpacing,
|
||||
}: AgentThoughtMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -1171,6 +1283,8 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||
onToggle={toggle}
|
||||
renderDetails={renderDetails}
|
||||
isLoading={status !== "ready"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1182,6 +1296,8 @@ interface ToolCallProps {
|
||||
error?: any;
|
||||
status: "executing" | "completed" | "failed";
|
||||
cwd?: string;
|
||||
isLastInSequence?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
// Icon mapping for tool kinds
|
||||
@@ -1212,6 +1328,8 @@ export const ToolCall = memo(function ToolCall({
|
||||
error,
|
||||
status,
|
||||
cwd,
|
||||
isLastInSequence = false,
|
||||
disableOuterSpacing,
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -1284,6 +1402,8 @@ export const ToolCall = memo(function ToolCall({
|
||||
}
|
||||
isLoading={status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user