diff --git a/.tasks/5eee8cb7.md b/.tasks/5eee8cb7.md new file mode 100644 index 000000000..e99e9c280 --- /dev/null +++ b/.tasks/5eee8cb7.md @@ -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. diff --git a/.tasks/89c1d14c.md b/.tasks/89c1d14c.md new file mode 100644 index 000000000..d0a3b37f7 --- /dev/null +++ b/.tasks/89c1d14c.md @@ -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. diff --git a/.tasks/933d0b5b.md b/.tasks/933d0b5b.md index fc7933b45..92d324e99 100644 --- a/.tasks/933d0b5b.md +++ b/.tasks/933d0b5b.md @@ -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. diff --git a/.tasks/947a21cd.md b/.tasks/947a21cd.md new file mode 100644 index 000000000..21a1f4f02 --- /dev/null +++ b/.tasks/947a21cd.md @@ -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. diff --git a/.tasks/a1e6eec8.md b/.tasks/a1e6eec8.md new file mode 100644 index 000000000..5f9ae1d79 --- /dev/null +++ b/.tasks/a1e6eec8.md @@ -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. diff --git a/01-main-screen.png b/01-main-screen.png new file mode 100644 index 000000000..bb0934794 Binary files /dev/null and b/01-main-screen.png differ diff --git a/02-agent-screen.png b/02-agent-screen.png new file mode 100644 index 000000000..cfd40908d Binary files /dev/null and b/02-agent-screen.png differ diff --git a/02-sidebar-open.png b/02-sidebar-open.png new file mode 100644 index 000000000..67c9ee751 Binary files /dev/null and b/02-sidebar-open.png differ diff --git a/03-after-slash.png b/03-after-slash.png new file mode 100644 index 000000000..2c7d23476 Binary files /dev/null and b/03-after-slash.png differ diff --git a/03-agent-screen.png b/03-agent-screen.png new file mode 100644 index 000000000..a45c39d11 Binary files /dev/null and b/03-agent-screen.png differ diff --git a/04-after-slash.png b/04-after-slash.png new file mode 100644 index 000000000..f28ca7e44 Binary files /dev/null and b/04-after-slash.png differ diff --git a/05-autocomplete-visible.png b/05-autocomplete-visible.png new file mode 100644 index 000000000..596686f0e Binary files /dev/null and b/05-autocomplete-visible.png differ diff --git a/packages/app/01-initial-state.png b/packages/app/01-initial-state.png new file mode 100644 index 000000000..a948d8116 Binary files /dev/null and b/packages/app/01-initial-state.png differ diff --git a/packages/app/maestro/command-autocomplete.yaml b/packages/app/maestro/command-autocomplete.yaml new file mode 100644 index 000000000..883ef3dd9 --- /dev/null +++ b/packages/app/maestro/command-autocomplete.yaml @@ -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.*" diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 82bcc7733..f20227429 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -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([]); 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 = ( <> )} + {/* Command autocomplete dropdown */} + {showCommandAutocomplete && isRealAgent && ( + + )} + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 69d6a0f79..2df1adc70 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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>(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) => { - 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 ( ); - break; + } case "assistant_message": - content = ( + return ( ); - break; - case "thought": - console.log("[AgentStreamView] renderStreamItem", { item }); - content = ( - + 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 ( + ); - 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 ( - ); - } else { - const data = payload.data; - content = ( - ); } - break; + + const data = payload.data; + return ( + + ); } case "activity_log": - content = ( + return ( ); - break; case "todo_list": - content = ( + return ( ); - break; default: - content = null; + return null; } + }, + [handleInlinePathPress, agent.cwd, flatListData] + ); + const renderStreamItem = useCallback( + ({ item, index }: ListRenderItemInfo) => { + const content = renderStreamItemContent(item, index); if (!content) { return null; } - return {content}; + const gap = getGapAbove(item, index); + + return ( + + {content} + + ); }, - [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 ? ( - {rendered} + + {rendered} + ) : 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 ( - item.id} - contentContainerStyle={{ - paddingVertical: 0, - flexGrow: 1, - }} - style={stylesheet.list} - onScroll={handleScroll} - onScrollBeginDrag={handleScrollBeginDrag} - onScrollEndDrag={handleScrollEnd} - onMomentumScrollBegin={handleMomentumScrollBegin} - onMomentumScrollEnd={handleScrollEnd} - scrollEventThrottle={16} - ListEmptyComponent={ - - - Start chatting with this agent... - - - } - ListHeaderComponent={listHeaderComponent} - extraData={flatListExtraData} - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - autoscrollToTopThreshold: 40, - }} - initialNumToRender={12} - windowSize={10} - inverted - /> + + item.id} + contentContainerStyle={{ + paddingVertical: 0, + flexGrow: 1, + }} + style={stylesheet.list} + onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEnd} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleScrollEnd} + scrollEventThrottle={16} + ListEmptyComponent={ + + + Start chatting with this agent... + + + } + ListHeaderComponent={listHeaderComponent} + extraData={flatListExtraData} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 40, + }} + initialNumToRender={12} + windowSize={10} + inverted + /> + {/* Scroll to bottom button */} {!isNearBottom && ( diff --git a/packages/app/src/components/command-autocomplete.tsx b/packages/app/src/components/command-autocomplete.tsx new file mode 100644 index 000000000..14751df10 --- /dev/null +++ b/packages/app/src/components/command-autocomplete.tsx @@ -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 ( + + + Loading commands... + + + ); + } + + if (isError) { + return ( + + + Error: {error?.message ?? "Failed to load"} + + + ); + } + + if (filteredCommands.length === 0) { + return ( + + + No commands found + + + ); + } + + return ( + + + {filteredCommands.map((cmd, index) => { + const isSelected = index === selectedIndex; + return ( + onSelect(cmd)} + style={[ + styles.commandItem, + isSelected && { + backgroundColor: theme.colors.accent, + }, + ]} + > + + /{cmd.name} + {cmd.argumentHint && ( + {cmd.argumentHint} + )} + + + {cmd.description} + + + ); + })} + + + ); +} + +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; diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index d001f01e1..d6085e68b 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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( rightContent, isAgentRunning = false, onQueue, + onKeyPress: onKeyPressCallback, }, ref ) { @@ -383,6 +386,16 @@ export const MessageInput = forwardRef( 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 diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 42214b1b7..db3ae77fc 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( + + {children} + + ); +} + +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 | null>(null); @@ -121,7 +165,19 @@ export const UserMessage = memo(function UserMessage({ }, []); return ( - + 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(null); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); @@ -499,7 +566,13 @@ export const AssistantMessage = memo(function AssistantMessage({ }, [onInlinePathPress]); return ( - + {message} @@ -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 ( - + @@ -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 ( - + ({ 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} /> ); }); diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts new file mode 100644 index 000000000..49ef1184e --- /dev/null +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -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, + }; +} diff --git a/packages/app/src/lib/send-rpc-request.ts b/packages/app/src/lib/send-rpc-request.ts index a4d6d471f..e76ad9f50 100644 --- a/packages/app/src/lib/send-rpc-request.ts +++ b/packages/app/src/lib/send-rpc-request.ts @@ -105,6 +105,7 @@ const RESPONSE_TYPE_MAP: Record = { 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", diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts new file mode 100644 index 000000000..11193d9fb --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts @@ -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); +}); diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 7d06e4c21..8d28dc66b 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -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; @@ -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; export type RestartServerRequestMessage = z.infer; export type ClearAgentAttentionMessage = z.infer; +export type ListCommandsRequest = z.infer; +export type ListCommandsResponse = z.infer; // ============================================================================ // WebSocket Level Messages (wraps session messages) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6db1f9b3d..e6ec4b676 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + 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 */ diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index cb53c1edb..e4d9bffb5 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -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 // ============================================================================