Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/[address]/[sessionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ export default function ChatSessionPage() {
setMode,
checkSessionStatus,
reconnect,
pause,
resume,
stopExecution,
sendInlineMessage,
executionState,
} = useAgentSDK({
agentAddress: address,
sessionId,
Expand Down Expand Up @@ -214,6 +219,7 @@ export default function ChatSessionPage() {
<Chat
ui={displayUI}
onSend={handleSend}
onInlineMessage={sendInlineMessage}
isLoading={isLoading || sendingInitial}
elapsedTime={elapsedTime}
suggestions={[]}
Expand All @@ -239,6 +245,9 @@ export default function ChatSessionPage() {
connectionError={connectionError}
onRetry={lastMessage ? () => handleSend(lastMessage) : undefined}
onReconnect={handleReconnect}
executionState={executionState}
isProcessing={isLoading}
onStopExecution={stopExecution}
/>
}
connectionError={connectionError}
Expand Down
14 changes: 12 additions & 2 deletions components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ChatInputProps, FileAttachment } from './types'

export function ChatInput({
onSend,
onInlineMessage,
isLoading = false,
placeholder = 'Message...',
statusBar,
Expand Down Expand Up @@ -48,7 +49,16 @@ export function ChatInput({

const handleSubmit = useCallback(() => {
const trimmed = value.trim()
if ((!trimmed && images.length === 0 && files.length === 0) || isLoading) return
if (!trimmed && images.length === 0 && files.length === 0) return

// During execution, route text-only messages as inline messages
if (isLoading && onInlineMessage && trimmed) {
onInlineMessage(trimmed)
setValue('')
return
}

if (isLoading) return

onSend(
trimmed,
Expand All @@ -59,7 +69,7 @@ export function ChatInput({
setImages([])
setFiles([])
// Height resets automatically via useEffect when value changes
}, [value, images, files, isLoading, onSend])
}, [value, images, files, isLoading, onSend, onInlineMessage])

const handleFileSelect = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files
Expand Down
2 changes: 2 additions & 0 deletions components/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ChatProps, ThinkingUI, UserUI } from './types'
export function Chat({
ui = [],
onSend,
onInlineMessage,
isLoading = false,
placeholder = 'Send a message...',
elapsedTime = 0,
Expand Down Expand Up @@ -111,6 +112,7 @@ export function Chat({
return (
<ChatInput
onSend={handleSend}
onInlineMessage={onInlineMessage}
isLoading={isLoading}
placeholder={inputPlaceholder}
statusBar={statusBar}
Expand Down
1 change: 1 addition & 0 deletions components/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { useAgentSDK, type SessionActiveState } from './use-agent-sdk'
export { ModeSwitcher, PlanModeBanner, UlwModeBanner } from './mode-switcher'
export { UlwToggle, UlwToggleWrapper } from './ulw-toggle'
export { ModeIndicator, ModeStatusBar } from './mode-indicator'
export { PipelineControl } from './pipeline-control'
export * from './messages'
export type {
FileAttachment,
Expand Down
121 changes: 67 additions & 54 deletions components/chat/mode-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useCallback } from 'react'
import { HiOutlineShieldCheck, HiOutlineClipboardList, HiOutlineLightningBolt } from 'react-icons/hi'
import { PipelineControl } from './pipeline-control'
import type { ApprovalMode } from './types'

interface ModeIndicatorProps {
Expand All @@ -17,6 +18,9 @@ interface ModeStatusBarProps extends ModeIndicatorProps {
connectionError?: string | null
onRetry?: () => void
onReconnect?: () => void
executionState?: 'running' | 'paused' | 'stopped' | null
isProcessing?: boolean
onStopExecution?: () => void
}

const BASE_MODES: ApprovalMode[] = ['safe', 'plan', 'accept_edits', 'ulw']
Expand Down Expand Up @@ -98,7 +102,7 @@ export function ModeIndicator({ mode, onModeChange, disabled }: ModeIndicatorPro
}

/** Left-right split status bar: connection on left, mode cycle on right */
export function ModeStatusBar({ mode, onModeChange, disabled, sessionState, connectionError, onRetry, onReconnect }: ModeStatusBarProps) {
export function ModeStatusBar({ mode, onModeChange, disabled, sessionState, connectionError, onRetry, onReconnect, executionState, isProcessing, onStopExecution }: ModeStatusBarProps) {
const currentMode = MODE_CONFIG[mode] || MODE_CONFIG.safe

const cycleMode = useCallback(() => {
Expand All @@ -125,60 +129,69 @@ export function ModeStatusBar({ mode, onModeChange, disabled, sessionState, conn
const showConnection = sessionState === 'active' || sessionState === 'connected' || sessionState === 'disconnected' || sessionState === 'reconnecting' || !!connectionError

return (
<div className="flex items-center justify-between">
{/* Left: Connection status */}
<div className="flex items-center gap-1.5">
{showConnection && (
connectionError ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
<span className="text-[11px] text-red-400">error</span>
{onRetry && (
<button onClick={onRetry} className="text-[11px] text-red-400 hover:text-red-600 underline">
retry
</button>
)}
</div>
) : sessionState === 'disconnected' ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-neutral-400" />
<span className="text-[11px] text-neutral-400">disconnected</span>
{onReconnect && (
<button onClick={onReconnect} className="text-[11px] text-neutral-400 hover:text-neutral-600 underline">
reconnect
</button>
)}
</div>
) : sessionState === 'active' ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
<span className="text-[11px] text-green-500">live</span>
</div>
) : sessionState === 'connected' ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-neutral-400" />
<span className="text-[11px] text-neutral-400">connected</span>
</div>
) : null
)}
</div>

{/* Right: Mode cycle */}
<button
onClick={cycleMode}
disabled={disabled}
className="text-[11px] text-neutral-400 hover:text-neutral-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={`${currentMode.shortLabel} mode · Click or ⇧Tab to cycle`}
>
{BASE_MODES.map((m, i) => (
<span key={m}>
{i > 0 && <span className="mx-0.5">·</span>}
<span className={m === mode ? 'text-neutral-700 font-medium' : ''}>
{MODE_CONFIG[m].shortLabel}
<div className="space-y-1">
{/* Pipeline control bar (auto-hides when not processing) */}
<PipelineControl
executionState={executionState ?? null}
isProcessing={isProcessing ?? false}
stopExecution={onStopExecution ?? (() => {})}
/>

<div className="flex items-center justify-between">
{/* Left: Connection status */}
<div className="flex items-center gap-1.5">
{showConnection && (
connectionError ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
<span className="text-[11px] text-red-400">error</span>
{onRetry && (
<button onClick={onRetry} className="text-[11px] text-red-400 hover:text-red-600 underline">
retry
</button>
)}
</div>
) : sessionState === 'disconnected' ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-neutral-400" />
<span className="text-[11px] text-neutral-400">disconnected</span>
{onReconnect && (
<button onClick={onReconnect} className="text-[11px] text-neutral-400 hover:text-neutral-600 underline">
reconnect
</button>
)}
</div>
) : sessionState === 'active' ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
<span className="text-[11px] text-green-500">live</span>
</div>
) : sessionState === 'connected' ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-neutral-400" />
<span className="text-[11px] text-neutral-400">connected</span>
</div>
) : null
)}
</div>

{/* Right: Mode cycle */}
<button
onClick={cycleMode}
disabled={disabled}
className="text-[11px] text-neutral-400 hover:text-neutral-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={`${currentMode.shortLabel} mode · Click or ⇧Tab to cycle`}
>
{BASE_MODES.map((m, i) => (
<span key={m}>
{i > 0 && <span className="mx-0.5">·</span>}
<span className={m === mode ? 'text-neutral-700 font-medium' : ''}>
{MODE_CONFIG[m].shortLabel}
</span>
</span>
</span>
))}
</button>
))}
</button>
</div>
</div>
)
}
35 changes: 35 additions & 0 deletions components/chat/pipeline-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client'

import { HiOutlineStop } from 'react-icons/hi'

interface PipelineControlProps {
executionState: 'running' | 'paused' | 'stopped' | null
isProcessing: boolean
stopExecution: () => void
}

export function PipelineControl({ executionState, isProcessing, stopExecution }: PipelineControlProps) {
// Only show when agent is actively working
if (!isProcessing) return null

const isStopped = (executionState ?? 'running') === 'stopped'

return (
<div className="flex items-center gap-2 py-1">
<div className="flex-1" />

{isStopped ? (
<span className="text-[11px] font-medium text-red-400">Stopped</span>
) : (
<button
onClick={stopExecution}
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-50 text-red-600 border border-red-200 hover:bg-red-100 transition-colors"
title="Stop agent"
>
<HiOutlineStop className="w-3 h-3" />
Stop
</button>
)}
</div>
)
}
4 changes: 4 additions & 0 deletions components/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ export interface SkillInfo {
export interface ChatProps {
ui?: UI[]
onSend: (message: string, images?: string[], files?: FileAttachment[]) => void
/** Send an inline message to the agent during execution */
onInlineMessage?: (content: string) => void
isLoading?: boolean
placeholder?: string
className?: string
Expand Down Expand Up @@ -331,6 +333,8 @@ export interface ChatMessageProps {

export interface ChatInputProps {
onSend: (message: string, images?: string[], files?: FileAttachment[]) => void
/** Send an inline message to the agent during execution (like Claude Code interjections) */
onInlineMessage?: (content: string) => void
isLoading?: boolean
placeholder?: string
className?: string
Expand Down
20 changes: 20 additions & 0 deletions components/chat/use-agent-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ interface UseAgentSDKReturn {
/** Reconnect to existing session to receive pending output */
reconnect: () => void
clear: () => void
/** Pause agent execution */
pause: () => void
/** Resume paused agent */
resume: () => void
/** Stop agent execution */
stopExecution: () => void
/** Send inline message during execution */
sendInlineMessage: (content: string) => void
/** Current execution state */
executionState: 'running' | 'paused' | 'stopped' | null
}

/**
Expand Down Expand Up @@ -145,6 +155,11 @@ export function useAgentSDK(options: UseAgentSDKOptions): UseAgentSDKReturn {
signOnboard,
setMode: sdkSetMode,
reconnect: sdkReconnect,
pause: sdkPause,
resume: sdkResume,
stopExecution: sdkStopExecution,
sendInlineMessage: sdkSendInlineMessage,
executionState,
} = useAgentForHuman(agentAddress, sessionId)

// Timer effect for elapsed time display
Expand Down Expand Up @@ -314,5 +329,10 @@ export function useAgentSDK(options: UseAgentSDKOptions): UseAgentSDKReturn {
checkSessionStatus,
reconnect: sdkReconnect,
clear,
pause: sdkPause,
resume: sdkResume,
stopExecution: sdkStopExecution,
sendInlineMessage: sdkSendInlineMessage,
executionState,
}
}