Add command autocomplete and stream spacing updates
18
.tasks/5eee8cb7.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
id: 5eee8cb7
|
||||
title: Fix inverted FlatList spacing
|
||||
status: done
|
||||
deps: []
|
||||
created: 2026-01-11T12:31:33.219Z
|
||||
---
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-11T12:31:44.968Z**
|
||||
|
||||
Need consistent 16px gaps between different content types in inverted FlatList; 4px gaps within groups (user messages, tool calls). Inverted list: marginBottom renders above, marginTop below. Check message.tsx + agent-stream-view.tsx, fix spacing, run typecheck.
|
||||
|
||||
**2026-01-11T12:45:33.340Z**
|
||||
|
||||
Centralized chat item spacing in AgentStreamView with inverted-list gap calculation (4px within user/tool groups, 16px otherwise). Added disableOuterSpacing prop to message components to keep other views unchanged. Typecheck passed.
|
||||
15
.tasks/89c1d14c.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: 89c1d14c
|
||||
title: Commit all current changes
|
||||
status: in_progress
|
||||
deps: []
|
||||
created: 2026-01-11T15:33:56.640Z
|
||||
---
|
||||
|
||||
## Goal\n- Stage and commit all current working tree changes, even if not authored by me.\n\n
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-11T15:34:06.250Z**
|
||||
|
||||
User requested: stage and commit all current changes, even if not authored by me.
|
||||
@@ -1,8 +1,14 @@
|
||||
---
|
||||
id: 933d0b5b
|
||||
title: Extend daemon client to support all daemon actions
|
||||
status: open
|
||||
status: in_progress
|
||||
deps: []
|
||||
created: 2026-01-08T16:15:09.344Z
|
||||
---
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-11T15:33:31.213Z**
|
||||
|
||||
User goal: build fully featured daemon client (no WS knowledge in app), support all RPC calls, app uses client, expand daemon e2e coverage to include all client actions.
|
||||
|
||||
22
.tasks/947a21cd.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: 947a21cd
|
||||
title: Simplify agent stream spacing props
|
||||
status: done
|
||||
deps: []
|
||||
created: 2026-01-11T15:24:58.595Z
|
||||
---
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-11T15:25:05.187Z**
|
||||
|
||||
User wants simplified spacing logic; reduce prop drilling without losing functionality.
|
||||
|
||||
**2026-01-11T15:28:57.437Z**
|
||||
|
||||
Added MessageOuterSpacingProvider context so stream view no longer passes disableOuterSpacing into each message; components now read spacing override from context.
|
||||
|
||||
**2026-01-11T15:29:14.746Z**
|
||||
|
||||
Simplified spacing by context: message components read disableOuterSpacing from MessageOuterSpacingProvider; AgentStreamView wraps FlatList and no longer prop-drills.
|
||||
22
.tasks/a1e6eec8.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: a1e6eec8
|
||||
title: Fix inverted chat spacing
|
||||
status: done
|
||||
deps: []
|
||||
created: 2026-01-11T13:15:47.498Z
|
||||
---
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-11T13:15:52.529Z**
|
||||
|
||||
User requires inverted FlatList spacing: only marginBottom on wrapper in agent-stream-view, child components zero margins when disableOuterSpacing. marginBottom based on next item: 4px if same type group (user-user, tool/thought-tool/thought), else 16px; first item marginBottom 0.
|
||||
|
||||
**2026-01-11T13:39:59.545Z**
|
||||
|
||||
Fix spacing by computing inverted-list gap from the item above (index + 1) instead of below; index 0 is bottom, topmost item gets 0 marginBottom.
|
||||
|
||||
**2026-01-11T13:40:18.974Z**
|
||||
|
||||
Adjusted inverted list spacing to compute marginBottom based on the item above (index + 1) so gaps match 4px/16px rules; ran typecheck.
|
||||
BIN
01-main-screen.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
02-agent-screen.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
02-sidebar-open.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
03-after-slash.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
03-agent-screen.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
04-after-slash.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
05-autocomplete-visible.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
packages/app/01-initial-state.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
57
packages/app/maestro/command-autocomplete.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
appId: com.moboudra.paseo
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- runFlow: flows/launch.yaml
|
||||
|
||||
# Take screenshot of main screen
|
||||
- takeScreenshot: 01-main-screen
|
||||
|
||||
# Swipe to open sidebar
|
||||
- swipe:
|
||||
direction: RIGHT
|
||||
duration: 300
|
||||
|
||||
# Wait for sidebar to open and show agents
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Wire POC Commands.*"
|
||||
timeout: 5000
|
||||
|
||||
# Take screenshot of sidebar
|
||||
- takeScreenshot: 02-sidebar-open
|
||||
|
||||
# Tap on the agent
|
||||
- tapOn:
|
||||
text: ".*Wire POC Commands.*"
|
||||
|
||||
# Wait for agent screen to load
|
||||
- extendedWaitUntil:
|
||||
visible: "Message agent..."
|
||||
timeout: 5000
|
||||
|
||||
# Take screenshot of agent screen
|
||||
- takeScreenshot: 03-agent-screen
|
||||
|
||||
# Tap on the message input to focus it
|
||||
- tapOn: "Message agent..."
|
||||
|
||||
# Type a slash to trigger autocomplete
|
||||
- inputText: "/"
|
||||
|
||||
# Wait a moment for autocomplete
|
||||
- waitForAnimationToEnd
|
||||
|
||||
# Take screenshot after typing /
|
||||
- takeScreenshot: 04-after-slash
|
||||
|
||||
# Wait for autocomplete to appear (use regex to match command name)
|
||||
- extendedWaitUntil:
|
||||
visible: ".*orchestrator-mode.*"
|
||||
timeout: 5000
|
||||
|
||||
# Take screenshot showing autocomplete
|
||||
- takeScreenshot: 05-autocomplete-visible
|
||||
|
||||
# Assert commands are visible (use regex to match)
|
||||
- assertVisible: ".*orchestrator-mode.*"
|
||||
- assertVisible: ".*prompt-engineer.*"
|
||||
@@ -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 && (
|
||||
|
||||
164
packages/app/src/components/command-autocomplete.tsx
Normal file
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
63
packages/app/src/hooks/use-agent-commands-query.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { sendRpcRequest } from "@/lib/send-rpc-request";
|
||||
|
||||
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
||||
|
||||
interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
}
|
||||
|
||||
function commandsQueryKey(serverId: string, agentId: string) {
|
||||
return ["agentCommands", serverId, agentId] as const;
|
||||
}
|
||||
|
||||
interface UseAgentCommandsQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled = true,
|
||||
}: UseAgentCommandsQueryOptions) {
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
|
||||
// Use getConnectionState for more reliable connection check
|
||||
const isConnected = ws?.getConnectionState
|
||||
? ws.getConnectionState().isConnected
|
||||
: ws?.isConnected ?? false;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: commandsQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!ws) {
|
||||
throw new Error("WebSocket not available");
|
||||
}
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "list_commands_request",
|
||||
agentId,
|
||||
});
|
||||
return response.commands as AgentSlashCommand[];
|
||||
},
|
||||
enabled: enabled && !!ws && isConnected && !!agentId,
|
||||
staleTime: COMMANDS_STALE_TIME,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
});
|
||||
|
||||
// isPending is true when the query has never run yet (no cached data and not fetching)
|
||||
// isLoading is true when fetching and no data yet
|
||||
const isLoading = query.isPending || query.isLoading;
|
||||
|
||||
return {
|
||||
commands: query.data ?? [],
|
||||
isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -105,6 +105,7 @@ const RESPONSE_TYPE_MAP: Record<string, SessionOutboundMessage["type"]> = {
|
||||
git_repo_info_request: "git_repo_info_response",
|
||||
list_provider_models_request: "list_provider_models_response",
|
||||
list_conversations_request: "list_conversations_response",
|
||||
list_commands_request: "list_commands_response",
|
||||
create_agent_request: "agent_state",
|
||||
refresh_agent_request: "agent_state",
|
||||
initialize_agent_request: "initialize_agent_request",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../../test-utils/index.js";
|
||||
import type { AgentSlashCommand } from "../agent-sdk-types.js";
|
||||
|
||||
describe("claude agent commands E2E", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
test("lists available slash commands for a claude agent", async () => {
|
||||
// Create a Claude agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
title: "Commands Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.provider).toBe("claude");
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// List commands
|
||||
const result = await ctx.client.listCommands(agent.id);
|
||||
|
||||
// Should have no error
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
// Should have commands
|
||||
expect(result.commands.length).toBeGreaterThan(0);
|
||||
|
||||
// Each command should have required fields
|
||||
for (const cmd of result.commands) {
|
||||
expect(cmd.name).toBeTruthy();
|
||||
expect(typeof cmd.description).toBe("string");
|
||||
expect(typeof cmd.argumentHint).toBe("string");
|
||||
}
|
||||
|
||||
// Should include some well-known commands
|
||||
const commandNames = result.commands.map((c) => c.name);
|
||||
// These are skills that come from CLAUDE.md configurations
|
||||
// At minimum we should have some commands available
|
||||
console.log("Available commands:", commandNames);
|
||||
expect(commandNames.length).toBeGreaterThan(0);
|
||||
}, 120000);
|
||||
|
||||
test("returns error for non-existent agent", async () => {
|
||||
const result = await ctx.client.listCommands("non-existent-agent-id");
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error).toContain("Agent not found");
|
||||
expect(result.commands).toEqual([]);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -523,6 +523,12 @@ export const ClearAgentAttentionMessageSchema = z.object({
|
||||
agentId: z.union([z.string(), z.array(z.string())]),
|
||||
});
|
||||
|
||||
export const ListCommandsRequestSchema = z.object({
|
||||
type: z.literal("list_commands_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UserTextMessageSchema,
|
||||
RealtimeAudioChunkMessageSchema,
|
||||
@@ -550,6 +556,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileDownloadTokenRequestSchema,
|
||||
GitRepoInfoRequestMessageSchema,
|
||||
ClearAgentAttentionMessageSchema,
|
||||
ListCommandsRequestSchema,
|
||||
]);
|
||||
|
||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||
@@ -805,6 +812,22 @@ export const ListProviderModelsResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const AgentSlashCommandSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
argumentHint: z.string(),
|
||||
});
|
||||
|
||||
export const ListCommandsResponseSchema = z.object({
|
||||
type: z.literal("list_commands_response"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
commands: z.array(AgentSlashCommandSchema),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
@@ -830,6 +853,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileDownloadTokenResponseSchema,
|
||||
GitRepoInfoResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListCommandsResponseSchema,
|
||||
]);
|
||||
|
||||
export type SessionOutboundMessage = z.infer<
|
||||
@@ -888,6 +912,8 @@ export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSc
|
||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
||||
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
|
||||
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Level Messages (wraps session messages)
|
||||
|
||||
@@ -794,6 +794,10 @@ export class Session {
|
||||
case "clear_agent_attention":
|
||||
await this.handleClearAgentAttention(msg.agentId);
|
||||
break;
|
||||
|
||||
case "list_commands_request":
|
||||
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
@@ -1818,6 +1822,73 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle list commands request for an agent
|
||||
*/
|
||||
private async handleListCommandsRequest(agentId: string, requestId?: string): Promise<void> {
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Handling list commands request for agent ${agentId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents();
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId,
|
||||
commands: [],
|
||||
error: `Agent not found: ${agentId}`,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = agent.session;
|
||||
if (!session || !session.listCommands) {
|
||||
this.emit({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId,
|
||||
commands: [],
|
||||
error: `Agent does not support listing commands`,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = await session.listCommands();
|
||||
|
||||
this.emit({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId,
|
||||
commands,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to list commands:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId,
|
||||
commands: [],
|
||||
error: error.message,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent permission response from user
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentSlashCommand,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
import { getAgentProviderDefinition } from "../agent/provider-registry.js";
|
||||
|
||||
@@ -616,6 +617,41 @@ export class DaemonClient {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Commands
|
||||
// ============================================================================
|
||||
|
||||
async listCommands(agentId: string): Promise<{
|
||||
agentId: string;
|
||||
commands: AgentSlashCommand[];
|
||||
error: string | null;
|
||||
}> {
|
||||
const startPosition = this.messageQueue.length;
|
||||
|
||||
this.send({
|
||||
type: "list_commands_request",
|
||||
agentId,
|
||||
});
|
||||
|
||||
return this.waitFor(
|
||||
(msg) => {
|
||||
if (
|
||||
msg.type === "list_commands_response" &&
|
||||
msg.payload.agentId === agentId
|
||||
) {
|
||||
return {
|
||||
agentId: msg.payload.agentId,
|
||||
commands: msg.payload.commands ?? [],
|
||||
error: msg.payload.error ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
30000,
|
||||
{ skipQueueBefore: startPosition }
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permissions
|
||||
// ============================================================================
|
||||
|
||||