Add command autocomplete and stream spacing updates
@@ -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.
|
||||||
@@ -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
|
id: 933d0b5b
|
||||||
title: Extend daemon client to support all daemon actions
|
title: Extend daemon client to support all daemon actions
|
||||||
status: open
|
status: in_progress
|
||||||
deps: []
|
deps: []
|
||||||
created: 2026-01-08T16:15:09.344Z
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 269 KiB |
@@ -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";
|
} from "./message-input";
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
|
import { CommandAutocomplete } from "./command-autocomplete";
|
||||||
|
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -127,6 +129,33 @@ export function AgentInputArea({
|
|||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
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 { pickImages } = useImageAttachmentPicker();
|
||||||
const agentIdRef = useRef(agentId);
|
const agentIdRef = useRef(agentId);
|
||||||
@@ -369,6 +398,64 @@ export function AgentInputArea({
|
|||||||
queueMessage(payload.text, payload.images);
|
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 = (
|
const rightContent = (
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -472,6 +559,17 @@ export function AgentInputArea({
|
|||||||
</View>
|
</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 handles everything: text, dictation, attachments, all buttons */}
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
@@ -492,6 +590,7 @@ export function AgentInputArea({
|
|||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
|
onKeyPress={handleCommandKeyPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
AgentThoughtMessage,
|
AgentThoughtMessage,
|
||||||
TodoListCard,
|
TodoListCard,
|
||||||
|
MessageOuterSpacingProvider,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
@@ -55,6 +56,10 @@ import {
|
|||||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
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<
|
type PermissionResolvedMessage = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "agent_permission_resolved" }
|
{ type: "agent_permission_resolved" }
|
||||||
@@ -75,6 +80,7 @@ export function AgentStreamView({
|
|||||||
pendingPermissions,
|
pendingPermissions,
|
||||||
}: AgentStreamViewProps) {
|
}: AgentStreamViewProps) {
|
||||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||||
const hasScrolledInitially = useRef(false);
|
const hasScrolledInitially = useRef(false);
|
||||||
@@ -257,43 +263,90 @@ export function AgentStreamView({
|
|||||||
setIsNearBottom(true);
|
setIsNearBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderStreamItem = useCallback(
|
const flatListData = useMemo(() => {
|
||||||
({ item }: ListRenderItemInfo<StreamItem>) => {
|
return [...streamItems].reverse();
|
||||||
let content: React.ReactNode = null;
|
}, [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) {
|
switch (item.kind) {
|
||||||
case "user_message":
|
case "user_message": {
|
||||||
content = (
|
// 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
|
<UserMessage
|
||||||
message={item.text}
|
message={item.text}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
|
isFirstInGroup={isFirstInGroup}
|
||||||
|
isLastInGroup={isLastInGroup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
|
|
||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
content = (
|
return (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
message={item.text}
|
message={item.text}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
onInlinePathPress={handleInlinePathPress}
|
onInlinePathPress={handleInlinePathPress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
|
|
||||||
case "thought":
|
case "thought": {
|
||||||
console.log("[AgentStreamView] renderStreamItem", { item });
|
// In inverted list: index+1 is the item above, index-1 is below.
|
||||||
content = (
|
const nextItem = flatListData[index - 1];
|
||||||
<AgentThoughtMessage message={item.text} status={item.status} />
|
const isLastInSequence =
|
||||||
|
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||||
|
return (
|
||||||
|
<AgentThoughtMessage
|
||||||
|
message={item.text}
|
||||||
|
status={item.status}
|
||||||
|
isLastInSequence={isLastInSequence}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
|
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
const { payload } = item;
|
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") {
|
if (payload.source === "agent") {
|
||||||
const data = payload.data;
|
const data = payload.data;
|
||||||
content = (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={data.name}
|
toolName={data.name}
|
||||||
args={data.input}
|
args={data.input}
|
||||||
@@ -301,24 +354,25 @@ export function AgentStreamView({
|
|||||||
error={data.error}
|
error={data.error}
|
||||||
status={data.status as "executing" | "completed" | "failed"}
|
status={data.status as "executing" | "completed" | "failed"}
|
||||||
cwd={agent.cwd}
|
cwd={agent.cwd}
|
||||||
/>
|
isLastInSequence={isLastInSequence}
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const data = payload.data;
|
|
||||||
content = (
|
|
||||||
<ToolCall
|
|
||||||
toolName={data.toolName}
|
|
||||||
args={data.arguments}
|
|
||||||
result={data.result}
|
|
||||||
status={data.status}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
const data = payload.data;
|
||||||
|
return (
|
||||||
|
<ToolCall
|
||||||
|
toolName={data.toolName}
|
||||||
|
args={data.arguments}
|
||||||
|
result={data.result}
|
||||||
|
status={data.status}
|
||||||
|
isLastInSequence={isLastInSequence}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "activity_log":
|
case "activity_log":
|
||||||
content = (
|
return (
|
||||||
<ActivityLog
|
<ActivityLog
|
||||||
type={item.activityType}
|
type={item.activityType}
|
||||||
message={item.message}
|
message={item.message}
|
||||||
@@ -326,29 +380,44 @@ export function AgentStreamView({
|
|||||||
metadata={item.metadata}
|
metadata={item.metadata}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
|
|
||||||
case "todo_list":
|
case "todo_list":
|
||||||
content = (
|
return (
|
||||||
<TodoListCard
|
<TodoListCard
|
||||||
provider={item.provider}
|
provider={item.provider}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
items={item.items}
|
items={item.items}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
content = null;
|
return null;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[handleInlinePathPress, agent.cwd, flatListData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStreamItem = useCallback(
|
||||||
|
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||||
|
const content = renderStreamItemContent(item, index);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return null;
|
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(
|
const pendingPermissionItems = useMemo(
|
||||||
@@ -391,18 +460,12 @@ export function AgentStreamView({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasHeadItems
|
{hasHeadItems
|
||||||
? [...streamHead].reverse().map((item) => {
|
? [...streamHead].reverse().map((item, index) => {
|
||||||
const rendered = renderStreamItem({
|
const rendered = renderStreamItemContent(item, index);
|
||||||
item,
|
|
||||||
index: 0,
|
|
||||||
separators: {
|
|
||||||
highlight: () => {},
|
|
||||||
unhighlight: () => {},
|
|
||||||
updateProps: () => {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return rendered ? (
|
return rendered ? (
|
||||||
<View key={item.id}>{rendered}</View>
|
<View key={item.id} style={stylesheet.streamItemWrapper}>
|
||||||
|
{rendered}
|
||||||
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
})
|
})
|
||||||
: null}
|
: null}
|
||||||
@@ -414,13 +477,9 @@ export function AgentStreamView({
|
|||||||
showWorkingIndicator,
|
showWorkingIndicator,
|
||||||
wsOrInert,
|
wsOrInert,
|
||||||
streamHead,
|
streamHead,
|
||||||
renderStreamItem,
|
renderStreamItemContent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const flatListData = useMemo(() => {
|
|
||||||
return [...streamItems].reverse();
|
|
||||||
}, [streamItems]);
|
|
||||||
|
|
||||||
const flatListExtraData = useMemo(
|
const flatListExtraData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
pendingPermissionCount: pendingPermissionItems.length,
|
pendingPermissionCount: pendingPermissionItems.length,
|
||||||
@@ -432,39 +491,41 @@ export function AgentStreamView({
|
|||||||
return (
|
return (
|
||||||
<ToolCallSheetProvider>
|
<ToolCallSheetProvider>
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
<FlatList
|
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||||
ref={flatListRef}
|
<FlatList
|
||||||
data={flatListData}
|
ref={flatListRef}
|
||||||
renderItem={renderStreamItem}
|
data={flatListData}
|
||||||
keyExtractor={(item) => item.id}
|
renderItem={renderStreamItem}
|
||||||
contentContainerStyle={{
|
keyExtractor={(item) => item.id}
|
||||||
paddingVertical: 0,
|
contentContainerStyle={{
|
||||||
flexGrow: 1,
|
paddingVertical: 0,
|
||||||
}}
|
flexGrow: 1,
|
||||||
style={stylesheet.list}
|
}}
|
||||||
onScroll={handleScroll}
|
style={stylesheet.list}
|
||||||
onScrollBeginDrag={handleScrollBeginDrag}
|
onScroll={handleScroll}
|
||||||
onScrollEndDrag={handleScrollEnd}
|
onScrollBeginDrag={handleScrollBeginDrag}
|
||||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
onScrollEndDrag={handleScrollEnd}
|
||||||
onMomentumScrollEnd={handleScrollEnd}
|
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||||
scrollEventThrottle={16}
|
onMomentumScrollEnd={handleScrollEnd}
|
||||||
ListEmptyComponent={
|
scrollEventThrottle={16}
|
||||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
ListEmptyComponent={
|
||||||
<Text style={stylesheet.emptyStateText}>
|
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||||
Start chatting with this agent...
|
<Text style={stylesheet.emptyStateText}>
|
||||||
</Text>
|
Start chatting with this agent...
|
||||||
</View>
|
</Text>
|
||||||
}
|
</View>
|
||||||
ListHeaderComponent={listHeaderComponent}
|
}
|
||||||
extraData={flatListExtraData}
|
ListHeaderComponent={listHeaderComponent}
|
||||||
maintainVisibleContentPosition={{
|
extraData={flatListExtraData}
|
||||||
minIndexForVisible: 0,
|
maintainVisibleContentPosition={{
|
||||||
autoscrollToTopThreshold: 40,
|
minIndexForVisible: 0,
|
||||||
}}
|
autoscrollToTopThreshold: 40,
|
||||||
initialNumToRender={12}
|
}}
|
||||||
windowSize={10}
|
initialNumToRender={12}
|
||||||
inverted
|
windowSize={10}
|
||||||
/>
|
inverted
|
||||||
|
/>
|
||||||
|
</MessageOuterSpacingProvider>
|
||||||
|
|
||||||
{/* Scroll to bottom button */}
|
{/* Scroll to bottom button */}
|
||||||
{!isNearBottom && (
|
{!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;
|
isAgentRunning?: boolean;
|
||||||
/** Callback for queue button when agent is running */
|
/** Callback for queue button when agent is running */
|
||||||
onQueue?: (payload: MessagePayload) => void;
|
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 {
|
export interface MessageInputRef {
|
||||||
@@ -110,6 +112,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
rightContent,
|
rightContent,
|
||||||
isAgentRunning = false,
|
isAgentRunning = false,
|
||||||
onQueue,
|
onQueue,
|
||||||
|
onKeyPress: onKeyPressCallback,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -383,6 +386,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
|
|
||||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||||
if (!shouldHandleDesktopSubmit) return;
|
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;
|
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||||
|
|
||||||
// Cmd+B or Ctrl+B: toggle sidebar
|
// Cmd+B or Ctrl+B: toggle sidebar
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { View, Text, Pressable, Animated } from "react-native";
|
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 { ReactNode, ComponentType } from "react";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
@@ -46,23 +55,53 @@ import {
|
|||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
timestamp: number;
|
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) => ({
|
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
marginBottom: theme.spacing[4],
|
|
||||||
marginTop: theme.spacing[4],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
containerSpacing: {
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
containerFirstInGroup: {
|
||||||
|
marginTop: theme.spacing[4],
|
||||||
|
},
|
||||||
|
containerLastInGroup: {
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
bubble: {
|
bubble: {
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: theme.colors.muted,
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
borderTopRightRadius: theme.borderRadius.sm,
|
borderTopRightRadius: theme.borderRadius.sm,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
maxWidth: "80%",
|
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -90,7 +129,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
export const UserMessage = memo(function UserMessage({
|
export const UserMessage = memo(function UserMessage({
|
||||||
message,
|
message,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
isFirstInGroup = true,
|
||||||
|
isLastInGroup = true,
|
||||||
|
disableOuterSpacing,
|
||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
|
const resolvedDisableOuterSpacing =
|
||||||
|
useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -121,7 +165,19 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={userMessageStylesheet.container}>
|
<View
|
||||||
|
style={[
|
||||||
|
userMessageStylesheet.container,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
userMessageStylesheet.containerSpacing,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
isFirstInGroup &&
|
||||||
|
userMessageStylesheet.containerFirstInGroup,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
isLastInGroup &&
|
||||||
|
userMessageStylesheet.containerLastInGroup,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Copy message"
|
accessibilityLabel="Copy message"
|
||||||
@@ -157,14 +213,17 @@ interface AssistantMessageProps {
|
|||||||
message: string;
|
message: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||||
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
marginBottom: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
containerSpacing: {
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
// Used in custom markdownRules for inline code styling
|
// Used in custom markdownRules for inline code styling
|
||||||
markdownCodeInline: {
|
markdownCodeInline: {
|
||||||
backgroundColor: theme.colors.secondary,
|
backgroundColor: theme.colors.secondary,
|
||||||
@@ -194,7 +253,12 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
marginHorizontal: theme.spacing[2],
|
marginHorizontal: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[2],
|
},
|
||||||
|
containerSpacing: {
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
containerLastInSequence: {
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
pressable: {
|
pressable: {
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
@@ -347,8 +411,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
message,
|
message,
|
||||||
timestamp,
|
timestamp,
|
||||||
onInlinePathPress,
|
onInlinePathPress,
|
||||||
|
disableOuterSpacing,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const resolvedDisableOuterSpacing =
|
||||||
|
useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const lastPathRef = useRef<string | null>(null);
|
const lastPathRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
@@ -499,7 +566,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
}, [onInlinePathPress]);
|
}, [onInlinePathPress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={assistantMessageStylesheet.container}>
|
<View
|
||||||
|
style={[
|
||||||
|
assistantMessageStylesheet.container,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
assistantMessageStylesheet.containerSpacing,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||||
{message}
|
{message}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
@@ -516,15 +589,18 @@ interface ActivityLogProps {
|
|||||||
artifactType?: string;
|
artifactType?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
onArtifactClick?: (artifactId: string) => void;
|
onArtifactClick?: (artifactId: string) => void;
|
||||||
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityLogStylesheet = StyleSheet.create((theme) => ({
|
const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||||
pressable: {
|
pressable: {
|
||||||
marginHorizontal: theme.spacing[2],
|
marginHorizontal: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
pressableSpacing: {
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
pressableActive: {
|
pressableActive: {
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
},
|
},
|
||||||
@@ -597,7 +673,10 @@ export const ActivityLog = memo(function ActivityLog({
|
|||||||
artifactType,
|
artifactType,
|
||||||
title,
|
title,
|
||||||
onArtifactClick,
|
onArtifactClick,
|
||||||
|
disableOuterSpacing,
|
||||||
}: ActivityLogProps) {
|
}: ActivityLogProps) {
|
||||||
|
const resolvedDisableOuterSpacing =
|
||||||
|
useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const typeConfig = {
|
const typeConfig = {
|
||||||
@@ -648,6 +727,8 @@ export const ActivityLog = memo(function ActivityLog({
|
|||||||
disabled={!isInteractive}
|
disabled={!isInteractive}
|
||||||
style={[
|
style={[
|
||||||
activityLogStylesheet.pressable,
|
activityLogStylesheet.pressable,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
activityLogStylesheet.pressableSpacing,
|
||||||
config.bg,
|
config.bg,
|
||||||
isInteractive && activityLogStylesheet.pressableActive,
|
isInteractive && activityLogStylesheet.pressableActive,
|
||||||
]}
|
]}
|
||||||
@@ -694,6 +775,7 @@ interface TodoListCardProps {
|
|||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
items: TodoEntry[];
|
items: TodoEntry[];
|
||||||
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPlanTimestamp(timestamp: number): string {
|
function formatPlanTimestamp(timestamp: number): string {
|
||||||
@@ -711,6 +793,8 @@ function formatPlanTimestamp(timestamp: number): string {
|
|||||||
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
marginHorizontal: theme.spacing[2],
|
marginHorizontal: theme.spacing[2],
|
||||||
|
},
|
||||||
|
containerSpacing: {
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
@@ -799,7 +883,10 @@ export const TodoListCard = memo(function TodoListCard({
|
|||||||
provider,
|
provider,
|
||||||
timestamp,
|
timestamp,
|
||||||
items,
|
items,
|
||||||
|
disableOuterSpacing,
|
||||||
}: TodoListCardProps) {
|
}: TodoListCardProps) {
|
||||||
|
const resolvedDisableOuterSpacing =
|
||||||
|
useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const providerLabel = useMemo(() => {
|
const providerLabel = useMemo(() => {
|
||||||
const definition = getAgentProviderDefinition(provider);
|
const definition = getAgentProviderDefinition(provider);
|
||||||
return definition?.label ?? provider;
|
return definition?.label ?? provider;
|
||||||
@@ -818,7 +905,13 @@ export const TodoListCard = memo(function TodoListCard({
|
|||||||
const iconColor = theme.colors.background;
|
const iconColor = theme.colors.background;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={todoListCardStylesheet.container}>
|
<View
|
||||||
|
style={[
|
||||||
|
todoListCardStylesheet.container,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
todoListCardStylesheet.containerSpacing,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={todoListCardStylesheet.card}>
|
<View style={todoListCardStylesheet.card}>
|
||||||
<View style={todoListCardStylesheet.header}>
|
<View style={todoListCardStylesheet.header}>
|
||||||
<View style={todoListCardStylesheet.headerMeta}>
|
<View style={todoListCardStylesheet.headerMeta}>
|
||||||
@@ -877,6 +970,8 @@ export const TodoListCard = memo(function TodoListCard({
|
|||||||
interface AgentThoughtMessageProps {
|
interface AgentThoughtMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
status?: ThoughtStatus;
|
status?: ThoughtStatus;
|
||||||
|
isLastInSequence?: boolean;
|
||||||
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpandableBadgeProps {
|
interface ExpandableBadgeProps {
|
||||||
@@ -888,6 +983,8 @@ interface ExpandableBadgeProps {
|
|||||||
renderDetails?: () => ReactNode;
|
renderDetails?: () => ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
isLastInSequence?: boolean;
|
||||||
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExpandableBadge = memo(function ExpandableBadge({
|
const ExpandableBadge = memo(function ExpandableBadge({
|
||||||
@@ -899,8 +996,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
renderDetails,
|
renderDetails,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isError = false,
|
isError = false,
|
||||||
|
isLastInSequence = false,
|
||||||
|
disableOuterSpacing,
|
||||||
}: ExpandableBadgeProps) {
|
}: ExpandableBadgeProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const resolvedDisableOuterSpacing =
|
||||||
|
useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const hasDetails = Boolean(renderDetails);
|
const hasDetails = Boolean(renderDetails);
|
||||||
const detailContent = hasDetails && isExpanded ? renderDetails?.() : null;
|
const detailContent = hasDetails && isExpanded ? renderDetails?.() : null;
|
||||||
|
|
||||||
@@ -956,7 +1057,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={expandableBadgeStylesheet.container}>
|
<View
|
||||||
|
style={[
|
||||||
|
expandableBadgeStylesheet.container,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
expandableBadgeStylesheet.containerSpacing,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
isLastInSequence &&
|
||||||
|
expandableBadgeStylesheet.containerLastInSequence,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={hasDetails ? onToggle : undefined}
|
onPress={hasDetails ? onToggle : undefined}
|
||||||
disabled={!hasDetails}
|
disabled={!hasDetails}
|
||||||
@@ -1016,6 +1126,8 @@ const agentThoughtStylesheet = StyleSheet.create((theme) => ({
|
|||||||
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||||
message,
|
message,
|
||||||
status = "ready",
|
status = "ready",
|
||||||
|
isLastInSequence = false,
|
||||||
|
disableOuterSpacing,
|
||||||
}: AgentThoughtMessageProps) {
|
}: AgentThoughtMessageProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
@@ -1171,6 +1283,8 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
|||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
renderDetails={renderDetails}
|
renderDetails={renderDetails}
|
||||||
isLoading={status !== "ready"}
|
isLoading={status !== "ready"}
|
||||||
|
isLastInSequence={isLastInSequence}
|
||||||
|
disableOuterSpacing={disableOuterSpacing}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1182,6 +1296,8 @@ interface ToolCallProps {
|
|||||||
error?: any;
|
error?: any;
|
||||||
status: "executing" | "completed" | "failed";
|
status: "executing" | "completed" | "failed";
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
isLastInSequence?: boolean;
|
||||||
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon mapping for tool kinds
|
// Icon mapping for tool kinds
|
||||||
@@ -1212,6 +1328,8 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
error,
|
error,
|
||||||
status,
|
status,
|
||||||
cwd,
|
cwd,
|
||||||
|
isLastInSequence = false,
|
||||||
|
disableOuterSpacing,
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
const { openToolCall } = useToolCallSheet();
|
const { openToolCall } = useToolCallSheet();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
@@ -1284,6 +1402,8 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
}
|
}
|
||||||
isLoading={status === "executing"}
|
isLoading={status === "executing"}
|
||||||
isError={status === "failed"}
|
isError={status === "failed"}
|
||||||
|
isLastInSequence={isLastInSequence}
|
||||||
|
disableOuterSpacing={disableOuterSpacing}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
git_repo_info_request: "git_repo_info_response",
|
||||||
list_provider_models_request: "list_provider_models_response",
|
list_provider_models_request: "list_provider_models_response",
|
||||||
list_conversations_request: "list_conversations_response",
|
list_conversations_request: "list_conversations_response",
|
||||||
|
list_commands_request: "list_commands_response",
|
||||||
create_agent_request: "agent_state",
|
create_agent_request: "agent_state",
|
||||||
refresh_agent_request: "agent_state",
|
refresh_agent_request: "agent_state",
|
||||||
initialize_agent_request: "initialize_agent_request",
|
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())]),
|
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", [
|
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
UserTextMessageSchema,
|
UserTextMessageSchema,
|
||||||
RealtimeAudioChunkMessageSchema,
|
RealtimeAudioChunkMessageSchema,
|
||||||
@@ -550,6 +556,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
GitRepoInfoRequestMessageSchema,
|
GitRepoInfoRequestMessageSchema,
|
||||||
ClearAgentAttentionMessageSchema,
|
ClearAgentAttentionMessageSchema,
|
||||||
|
ListCommandsRequestSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
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", [
|
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
ActivityLogMessageSchema,
|
ActivityLogMessageSchema,
|
||||||
AssistantChunkMessageSchema,
|
AssistantChunkMessageSchema,
|
||||||
@@ -830,6 +853,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
GitRepoInfoResponseSchema,
|
GitRepoInfoResponseSchema,
|
||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
|
ListCommandsResponseSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionOutboundMessage = z.infer<
|
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 FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||||
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
||||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
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)
|
// WebSocket Level Messages (wraps session messages)
|
||||||
|
|||||||
@@ -794,6 +794,10 @@ export class Session {
|
|||||||
case "clear_agent_attention":
|
case "clear_agent_attention":
|
||||||
await this.handleClearAgentAttention(msg.agentId);
|
await this.handleClearAgentAttention(msg.agentId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "list_commands_request":
|
||||||
|
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(
|
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
|
* Handle agent permission response from user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
|
AgentSlashCommand,
|
||||||
} from "../agent/agent-sdk-types.js";
|
} from "../agent/agent-sdk-types.js";
|
||||||
import { getAgentProviderDefinition } from "../agent/provider-registry.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
|
// Permissions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||