Initial commit
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import type { PlanEntryInfo } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CircleDashedIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface AgentPlanOverlayProps {
|
||||
message?: LiveMessage | null
|
||||
entries?: PlanEntryInfo[] | null
|
||||
planKey?: string | null
|
||||
visible?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
function getLatestPlanEntries(message: LiveMessage | null): PlanEntryInfo[] {
|
||||
if (!message) return []
|
||||
|
||||
for (let i = message.content.length - 1; i >= 0; i -= 1) {
|
||||
const block = message.content[i]
|
||||
if (block.type === "plan") {
|
||||
return block.entries
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "in_progress":
|
||||
return "In Progress"
|
||||
case "pending":
|
||||
return "Pending"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority: string): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "High"
|
||||
case "medium":
|
||||
return "Medium"
|
||||
case "low":
|
||||
return "Low"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityClassName(priority: string): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "text-red-700 bg-red-500/10 border-red-500/20 dark:text-red-300"
|
||||
case "medium":
|
||||
return "text-amber-700 bg-amber-500/10 border-amber-500/20 dark:text-amber-300"
|
||||
case "low":
|
||||
return "text-slate-700 bg-slate-500/10 border-slate-500/20 dark:text-slate-300"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2Icon className="h-3.5 w-3.5 text-emerald-500" />
|
||||
}
|
||||
|
||||
if (status === "in_progress") {
|
||||
return <Loader2Icon className="h-3.5 w-3.5 text-blue-500 animate-spin" />
|
||||
}
|
||||
|
||||
return <CircleDashedIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
}
|
||||
|
||||
export const AgentPlanOverlay = memo(function AgentPlanOverlay({
|
||||
message,
|
||||
entries,
|
||||
planKey,
|
||||
visible = true,
|
||||
defaultExpanded = true,
|
||||
}: AgentPlanOverlayProps) {
|
||||
const liveEntries = useMemo(
|
||||
() => getLatestPlanEntries(message ?? null),
|
||||
[message]
|
||||
)
|
||||
const resolvedEntries = useMemo(
|
||||
() => (liveEntries.length > 0 ? liveEntries : (entries ?? [])),
|
||||
[liveEntries, entries]
|
||||
)
|
||||
const hasPlan = visible && resolvedEntries.length > 0
|
||||
const fallbackPlanKey = useMemo(() => {
|
||||
if (resolvedEntries.length === 0) return null
|
||||
return resolvedEntries
|
||||
.map((entry) => `${entry.status}:${entry.priority}:${entry.content}`)
|
||||
.join("|")
|
||||
}, [resolvedEntries])
|
||||
const currentPlanKey = planKey ?? message?.id ?? fallbackPlanKey
|
||||
|
||||
const completedCount = useMemo(
|
||||
() =>
|
||||
resolvedEntries.filter((entry) => entry.status === "completed").length,
|
||||
[resolvedEntries]
|
||||
)
|
||||
const hasIncompleteEntries = completedCount < resolvedEntries.length
|
||||
const resolvedDefaultExpanded = defaultExpanded && hasIncompleteEntries
|
||||
const currentPlanStateKey = currentPlanKey ?? "__plan__default__"
|
||||
const [collapsedByPlanKey, setCollapsedByPlanKey] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const isExpanded = !(
|
||||
collapsedByPlanKey[currentPlanStateKey] ?? !resolvedDefaultExpanded
|
||||
)
|
||||
|
||||
if (!hasPlan) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute right-8 top-4 z-20 flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="cursor-pointer pointer-events-auto shadow-md bg-secondary/70 hover:bg-secondary"
|
||||
onClick={() =>
|
||||
setCollapsedByPlanKey((prev) => ({
|
||||
...prev,
|
||||
[currentPlanStateKey]: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<ListTodoIcon className="h-4 w-4" />
|
||||
Plan {completedCount}/{resolvedEntries.length}
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute right-8 top-4 z-20 flex max-w-[min(22rem,calc(100%-2rem))]"
|
||||
data-plan-key={currentPlanKey ?? undefined}
|
||||
>
|
||||
<div className="pointer-events-auto w-72 max-w-full rounded-xl border bg-card/60 hover:bg-card/95 shadow-lg backdrop-blur transition-colors supports-[backdrop-filter]:bg-card/50 supports-[backdrop-filter]:hover:bg-card/85">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ListTodoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">Agent Plan</span>
|
||||
<Badge variant="secondary" className="h-5">
|
||||
{completedCount}/{resolvedEntries.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Collapse plan"
|
||||
onClick={() =>
|
||||
setCollapsedByPlanKey((prev) => ({
|
||||
...prev,
|
||||
[currentPlanStateKey]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto p-3 space-y-2">
|
||||
{resolvedEntries.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.content}-${index}`}
|
||||
className="rounded-lg border bg-transparent px-2.5 py-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={entry.status} />
|
||||
<p
|
||||
className={cn(
|
||||
"min-w-0 flex-1 text-sm leading-5 break-words [overflow-wrap:anywhere]",
|
||||
entry.status === "completed"
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{entry.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 pl-5">
|
||||
<Badge variant="outline" className="h-5 text-[10px] uppercase">
|
||||
{getStatusLabel(entry.status)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 text-[10px] uppercase",
|
||||
getPriorityClassName(entry.priority)
|
||||
)}
|
||||
>
|
||||
{getPriorityLabel(entry.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { acpListAgents } from "@/lib/tauri"
|
||||
import type { AgentType, AcpAgentInfo } from "@/lib/types"
|
||||
import { AGENT_LABELS } from "@/lib/types"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface AgentSelectorProps {
|
||||
defaultAgentType?: AgentType
|
||||
onSelect: (agentType: AgentType) => void
|
||||
onAgentsLoaded?: (agents: AcpAgentInfo[]) => void
|
||||
onOpenAgentsSettings?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AgentSelector({
|
||||
defaultAgentType,
|
||||
onSelect,
|
||||
onAgentsLoaded,
|
||||
onOpenAgentsSettings,
|
||||
disabled = false,
|
||||
}: AgentSelectorProps) {
|
||||
const [agents, setAgents] = useState<AcpAgentInfo[]>([])
|
||||
const [selected, setSelected] = useState<AgentType | null>(
|
||||
defaultAgentType ?? null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
acpListAgents()
|
||||
.then((list) => {
|
||||
if (cancelled) return
|
||||
const sorted = [...list].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)
|
||||
)
|
||||
const visible = sorted.filter((a) => a.enabled)
|
||||
setAgents(visible)
|
||||
onAgentsLoaded?.(visible)
|
||||
// Auto-select default if it exists in the list
|
||||
if (defaultAgentType) {
|
||||
const found = visible.find(
|
||||
(a) => a.agent_type === defaultAgentType && a.available
|
||||
)
|
||||
if (found) {
|
||||
setSelected(found.agent_type)
|
||||
} else {
|
||||
// Fall back to first available
|
||||
const first = visible.find((a) => a.available)
|
||||
if (first) {
|
||||
setSelected(first.agent_type)
|
||||
onSelect(first.agent_type)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const first = visible.find((a) => a.available)
|
||||
if (first) {
|
||||
setSelected(first.agent_type)
|
||||
onSelect(first.agent_type)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAgents([])
|
||||
onAgentsLoaded?.([])
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [defaultAgentType, onAgentsLoaded, onSelect])
|
||||
|
||||
const handleSelect = (agentType: AgentType) => {
|
||||
setSelected(agentType)
|
||||
onSelect(agentType)
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
<div>暂无已启用的 Agent</div>
|
||||
{onOpenAgentsSettings ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAgentsSettings}
|
||||
className="mt-2 inline-flex items-center rounded-md border px-2 py-1 text-xs text-foreground transition-colors hover:bg-accent cursor-pointer"
|
||||
>
|
||||
打开 Agents 设置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.agent_type}
|
||||
disabled={disabled || !agent.available}
|
||||
onClick={() => handleSelect(agent.agent_type)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
"border",
|
||||
disabled || !agent.available
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "cursor-pointer hover:bg-accent",
|
||||
selected === agent.agent_type
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<AgentIcon agentType={agent.agent_type} className="w-3.5 h-3.5" />
|
||||
{AGENT_LABELS[agent.agent_type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
PromptDraft,
|
||||
SessionConfigOptionInfo,
|
||||
SessionModeInfo,
|
||||
AvailableCommandInfo,
|
||||
} from "@/lib/types"
|
||||
import { MessageInput } from "@/components/chat/message-input"
|
||||
|
||||
interface ChatInputProps {
|
||||
status: ConnectionStatus | null
|
||||
defaultPath?: string
|
||||
onFocus?: () => void
|
||||
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
||||
onCancel: () => void
|
||||
modes?: SessionModeInfo[]
|
||||
configOptions?: SessionConfigOptionInfo[]
|
||||
modeLoading?: boolean
|
||||
configOptionsLoading?: boolean
|
||||
selectedModeId?: string | null
|
||||
onModeChange?: (modeId: string) => void
|
||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||
availableCommands?: AvailableCommandInfo[] | null
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
status,
|
||||
defaultPath,
|
||||
onFocus,
|
||||
onSend,
|
||||
onCancel,
|
||||
modes,
|
||||
configOptions,
|
||||
modeLoading = false,
|
||||
configOptionsLoading = false,
|
||||
selectedModeId,
|
||||
onModeChange,
|
||||
onConfigOptionChange,
|
||||
availableCommands,
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
}: ChatInputProps) {
|
||||
const isConnected = status === "connected"
|
||||
const isPrompting = status === "prompting"
|
||||
const isConnecting = status === "connecting" || status === "downloading"
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-0">
|
||||
<MessageInput
|
||||
onSend={onSend}
|
||||
onFocus={onFocus}
|
||||
defaultPath={defaultPath}
|
||||
disabled={!isConnected}
|
||||
isPrompting={isPrompting}
|
||||
onCancel={onCancel}
|
||||
modes={modes}
|
||||
configOptions={configOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={onModeChange}
|
||||
onConfigOptionChange={onConfigOptionChange}
|
||||
availableCommands={availableCommands}
|
||||
attachmentTabId={attachmentTabId}
|
||||
draftStorageKey={draftStorageKey}
|
||||
placeholder={
|
||||
isConnecting
|
||||
? "Connecting..."
|
||||
: isPrompting
|
||||
? "Agent is responding..."
|
||||
: "Send a message..."
|
||||
}
|
||||
className="min-h-28 max-h-60"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ConnectionStatus } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
connected: { color: "bg-green-500", label: "Agent connected" },
|
||||
connecting: {
|
||||
color: "bg-blue-500 animate-pulse",
|
||||
label: "Connecting...",
|
||||
},
|
||||
prompting: {
|
||||
color: "bg-yellow-500 animate-pulse",
|
||||
label: "Agent responding...",
|
||||
},
|
||||
error: { color: "bg-red-500", label: "Connection error" },
|
||||
}
|
||||
|
||||
interface ConnectionStatusIndicatorProps {
|
||||
status: ConnectionStatus | null | undefined
|
||||
}
|
||||
|
||||
export function ConnectionStatusIndicator({
|
||||
status,
|
||||
}: ConnectionStatusIndicatorProps) {
|
||||
if (!status || status === "disconnected") return null
|
||||
|
||||
const config = STATUS_CONFIG[status]
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<div className="px-4 py-1 text-xs text-muted-foreground border-t border-border flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn("inline-block h-1.5 w-1.5 rounded-full", config.color)}
|
||||
/>
|
||||
{config.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
PromptDraft,
|
||||
SessionConfigOptionInfo,
|
||||
SessionModeInfo,
|
||||
AvailableCommandInfo,
|
||||
} from "@/lib/types"
|
||||
import type { PendingPermission } from "@/contexts/acp-connections-context"
|
||||
import { ChatInput } from "@/components/chat/chat-input"
|
||||
import { PermissionDialog } from "@/components/chat/permission-dialog"
|
||||
|
||||
interface ConversationShellProps {
|
||||
status: ConnectionStatus | null
|
||||
defaultPath?: string
|
||||
error: string | null
|
||||
pendingPermission: PendingPermission | null
|
||||
onFocus: () => void
|
||||
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
||||
onCancel: () => void
|
||||
onRespondPermission: (requestId: string, optionId: string) => void
|
||||
children: ReactNode
|
||||
modes?: SessionModeInfo[]
|
||||
configOptions?: SessionConfigOptionInfo[]
|
||||
modeLoading?: boolean
|
||||
configOptionsLoading?: boolean
|
||||
selectedModeId?: string | null
|
||||
onModeChange?: (modeId: string) => void
|
||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||
availableCommands?: AvailableCommandInfo[] | null
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
}
|
||||
|
||||
export function ConversationShell({
|
||||
status,
|
||||
defaultPath,
|
||||
error,
|
||||
pendingPermission,
|
||||
onFocus,
|
||||
onSend,
|
||||
onCancel,
|
||||
onRespondPermission,
|
||||
children,
|
||||
modes,
|
||||
configOptions,
|
||||
modeLoading = false,
|
||||
configOptionsLoading = false,
|
||||
selectedModeId,
|
||||
onModeChange,
|
||||
onConfigOptionChange,
|
||||
availableCommands,
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
}: ConversationShellProps) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex-1 min-h-0">{children}</div>
|
||||
|
||||
<PermissionDialog
|
||||
permission={pendingPermission}
|
||||
onRespond={onRespondPermission}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
status={status}
|
||||
defaultPath={defaultPath}
|
||||
onFocus={onFocus}
|
||||
onSend={onSend}
|
||||
onCancel={onCancel}
|
||||
modes={modes}
|
||||
configOptions={configOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={onModeChange}
|
||||
onConfigOptionChange={onConfigOptionChange}
|
||||
availableCommands={availableCommands}
|
||||
attachmentTabId={attachmentTabId}
|
||||
draftStorageKey={draftStorageKey}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-2 text-xs text-destructive bg-destructive/5 border-t border-destructive/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
interface DropdownRadioItemContentProps {
|
||||
label: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export function DropdownRadioItemContent({
|
||||
label,
|
||||
description,
|
||||
}: DropdownRadioItemContentProps) {
|
||||
const normalizedDescription = description?.trim()
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 pr-2">
|
||||
<p className="truncate">{label}</p>
|
||||
{normalizedDescription ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-snug whitespace-pre-wrap wrap-break-word">
|
||||
{normalizedDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useMemo } from "react"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
|
||||
import { adaptLiveMessageFromAcp } from "@/lib/adapters/ai-elements-adapter"
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
|
||||
interface LiveMessageBlockProps {
|
||||
message: LiveMessage
|
||||
}
|
||||
|
||||
export const LiveMessageBlock = memo(function LiveMessageBlock({
|
||||
message,
|
||||
}: LiveMessageBlockProps) {
|
||||
const hasContent = message.content.length > 0
|
||||
const adapted = useMemo(() => adaptLiveMessageFromAcp(message), [message])
|
||||
|
||||
return (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{hasContent ? (
|
||||
<ContentPartsRenderer parts={adapted.content} role="assistant" />
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-muted-foreground py-1"
|
||||
aria-label="Assistant is thinking"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]" />
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]" />
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce" />
|
||||
</div>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,481 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { FileSearch, Plus, Send, Square, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type {
|
||||
AvailableCommandInfo,
|
||||
PromptDraft,
|
||||
PromptInputBlock,
|
||||
SessionConfigOptionInfo,
|
||||
SessionModeInfo,
|
||||
} from "@/lib/types"
|
||||
import {
|
||||
ATTACH_FILE_TO_SESSION_EVENT,
|
||||
type AttachFileToSessionDetail,
|
||||
} from "@/lib/session-attachment-events"
|
||||
import { ModeSelector } from "@/components/chat/mode-selector"
|
||||
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
|
||||
import { SlashCommandMenu } from "@/components/chat/slash-command-menu"
|
||||
import {
|
||||
clearMessageInputDraft,
|
||||
loadMessageInputDraft,
|
||||
saveMessageInputDraft,
|
||||
} from "@/lib/message-input-draft"
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
||||
placeholder?: string
|
||||
defaultPath?: string
|
||||
disabled?: boolean
|
||||
autoFocus?: boolean
|
||||
onFocus?: () => void
|
||||
className?: string
|
||||
isPrompting?: boolean
|
||||
onCancel?: () => void
|
||||
modes?: SessionModeInfo[]
|
||||
configOptions?: SessionConfigOptionInfo[]
|
||||
modeLoading?: boolean
|
||||
configOptionsLoading?: boolean
|
||||
selectedModeId?: string | null
|
||||
onModeChange?: (modeId: string) => void
|
||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||
availableCommands?: AvailableCommandInfo[] | null
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
}
|
||||
|
||||
interface InputAttachment {
|
||||
path: string
|
||||
uri: string
|
||||
name: string
|
||||
mimeType: string | null
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
txt: "text/plain",
|
||||
md: "text/markdown",
|
||||
json: "application/json",
|
||||
yaml: "application/yaml",
|
||||
yml: "application/yaml",
|
||||
csv: "text/csv",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
js: "text/javascript",
|
||||
mjs: "text/javascript",
|
||||
cjs: "text/javascript",
|
||||
ts: "text/typescript",
|
||||
tsx: "text/tsx",
|
||||
jsx: "text/jsx",
|
||||
py: "text/x-python",
|
||||
rs: "text/rust",
|
||||
go: "text/x-go",
|
||||
java: "text/x-java-source",
|
||||
xml: "application/xml",
|
||||
toml: "application/toml",
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
return path.split(/[/\\]/).pop() || path
|
||||
}
|
||||
|
||||
function mimeTypeFromPath(path: string): string | null {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? ""
|
||||
return MIME_BY_EXT[ext] ?? null
|
||||
}
|
||||
|
||||
function toFileUri(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/")
|
||||
const encoded = normalized.split("/").map(encodeURIComponent).join("/")
|
||||
if (normalized.startsWith("/")) {
|
||||
return `file://${encoded}`
|
||||
}
|
||||
return `file:///${encoded}`
|
||||
}
|
||||
|
||||
function SelectorLoadingChip({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageInput({
|
||||
onSend,
|
||||
placeholder = "Ask anything...",
|
||||
defaultPath,
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
onFocus,
|
||||
className,
|
||||
isPrompting = false,
|
||||
onCancel,
|
||||
modes,
|
||||
configOptions,
|
||||
modeLoading = false,
|
||||
configOptionsLoading = false,
|
||||
selectedModeId,
|
||||
onModeChange,
|
||||
onConfigOptionChange,
|
||||
availableCommands,
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
}: MessageInputProps) {
|
||||
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
|
||||
const [text, setText] = useState(() => {
|
||||
if (!effectiveDraftStorageKey) return ""
|
||||
return loadMessageInputDraft(effectiveDraftStorageKey) ?? ""
|
||||
})
|
||||
const [attachments, setAttachments] = useState<InputAttachment[]>([])
|
||||
const composingRef = useRef(false)
|
||||
const textRef = useRef(text)
|
||||
|
||||
useEffect(() => {
|
||||
textRef.current = text
|
||||
}, [text])
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveDraftStorageKey) return
|
||||
saveMessageInputDraft(effectiveDraftStorageKey, text)
|
||||
}, [effectiveDraftStorageKey, text])
|
||||
|
||||
const availableModes = useMemo(() => modes ?? [], [modes])
|
||||
const availableConfigOptions = useMemo(
|
||||
() => configOptions ?? [],
|
||||
[configOptions]
|
||||
)
|
||||
const hasConfigOptions = availableConfigOptions.length > 0
|
||||
const hasModes = availableModes.length > 0
|
||||
|
||||
const effectiveModeId = useMemo(() => {
|
||||
if (!hasModes) return null
|
||||
if (
|
||||
selectedModeId &&
|
||||
availableModes.some((mode) => mode.id === selectedModeId)
|
||||
) {
|
||||
return selectedModeId
|
||||
}
|
||||
return availableModes[0]?.id ?? null
|
||||
}, [hasModes, selectedModeId, availableModes])
|
||||
const showModeSelector =
|
||||
hasModes && Boolean(effectiveModeId) && !hasConfigOptions
|
||||
const showModeLoading = modeLoading && !hasConfigOptions && !showModeSelector
|
||||
const showConfigLoading = configOptionsLoading && !hasConfigOptions
|
||||
const hasAttachments = attachments.length > 0
|
||||
const hasSendableContent = text.trim().length > 0 || hasAttachments
|
||||
|
||||
// ── Slash command autocomplete ──
|
||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const slashCommands = useMemo(
|
||||
() => availableCommands ?? [],
|
||||
[availableCommands]
|
||||
)
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (!slashMenuOpen || slashCommands.length === 0) return []
|
||||
const match = text.match(/^\/(\S*)$/)
|
||||
if (!match) return []
|
||||
const filter = match[1].toLowerCase()
|
||||
return slashCommands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().startsWith(filter)
|
||||
)
|
||||
}, [slashMenuOpen, slashCommands, text])
|
||||
|
||||
const appendAttachments = useCallback((paths: string[]) => {
|
||||
setAttachments((prev) => {
|
||||
const seen = new Set(prev.map((item) => item.path))
|
||||
const next = [...prev]
|
||||
for (const path of paths) {
|
||||
if (typeof path !== "string" || !path || seen.has(path)) continue
|
||||
seen.add(path)
|
||||
next.push({
|
||||
path,
|
||||
uri: toFileUri(path),
|
||||
name: fileNameFromPath(path),
|
||||
mimeType: mimeTypeFromPath(path),
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showModeSelector) return
|
||||
if (!effectiveModeId || !onModeChange) return
|
||||
if (effectiveModeId !== selectedModeId) {
|
||||
onModeChange(effectiveModeId)
|
||||
}
|
||||
}, [showModeSelector, effectiveModeId, selectedModeId, onModeChange])
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(modeId: string) => {
|
||||
onModeChange?.(modeId)
|
||||
},
|
||||
[onModeChange]
|
||||
)
|
||||
|
||||
const handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => {
|
||||
setText(`/${cmd.name} `)
|
||||
setSlashMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setText(value)
|
||||
if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) {
|
||||
setSlashSelectedIndex(0)
|
||||
setSlashMenuOpen(true)
|
||||
} else {
|
||||
setSlashMenuOpen(false)
|
||||
}
|
||||
},
|
||||
[slashCommands.length]
|
||||
)
|
||||
|
||||
const handlePickFiles = useCallback(async () => {
|
||||
if (disabled) return
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
defaultPath: defaultPath || undefined,
|
||||
})
|
||||
if (!selected) return
|
||||
const picked = Array.isArray(selected) ? selected : [selected]
|
||||
appendAttachments(picked.filter((item): item is string => !!item))
|
||||
} catch (error) {
|
||||
console.error("[MessageInput] pick files failed:", error)
|
||||
}
|
||||
}, [appendAttachments, defaultPath, disabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!attachmentTabId) return
|
||||
|
||||
const handleAttachFile = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<AttachFileToSessionDetail>
|
||||
if (!customEvent.detail) return
|
||||
if (customEvent.detail.tabId !== attachmentTabId) return
|
||||
appendAttachments([customEvent.detail.path])
|
||||
}
|
||||
|
||||
window.addEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
||||
return () => {
|
||||
window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
||||
}
|
||||
}, [appendAttachments, attachmentTabId])
|
||||
|
||||
const removeAttachment = useCallback((path: string) => {
|
||||
setAttachments((prev) => prev.filter((item) => item.path !== path))
|
||||
}, [])
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = textRef.current.trim()
|
||||
if (!trimmed && attachments.length === 0) return
|
||||
|
||||
const blocks: PromptInputBlock[] = []
|
||||
if (trimmed) {
|
||||
blocks.push({ type: "text", text: trimmed })
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
blocks.push({
|
||||
type: "resource_link",
|
||||
uri: attachment.uri,
|
||||
name: attachment.name,
|
||||
mime_type: attachment.mimeType,
|
||||
description: null,
|
||||
})
|
||||
}
|
||||
|
||||
const displayText =
|
||||
trimmed ||
|
||||
`Attached ${attachments.length} resource${attachments.length > 1 ? "s" : ""}`
|
||||
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
|
||||
if (effectiveDraftStorageKey) {
|
||||
clearMessageInputDraft(effectiveDraftStorageKey)
|
||||
}
|
||||
setText("")
|
||||
setAttachments([])
|
||||
}, [
|
||||
attachments,
|
||||
onSend,
|
||||
effectiveModeId,
|
||||
showModeSelector,
|
||||
effectiveDraftStorageKey,
|
||||
])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (
|
||||
e.nativeEvent.isComposing ||
|
||||
composingRef.current ||
|
||||
e.key === "Process" ||
|
||||
e.keyCode === 229
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (slashMenuOpen && filteredSlashCommands.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSlashSelectedIndex((i) =>
|
||||
i < filteredSlashCommands.length - 1 ? i + 1 : 0
|
||||
)
|
||||
return
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSlashSelectedIndex((i) =>
|
||||
i > 0 ? i - 1 : filteredSlashCommands.length - 1
|
||||
)
|
||||
return
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
|
||||
return
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setSlashMenuOpen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!disabled) handleSend()
|
||||
}
|
||||
},
|
||||
[
|
||||
disabled,
|
||||
handleSend,
|
||||
slashMenuOpen,
|
||||
filteredSlashCommands,
|
||||
slashSelectedIndex,
|
||||
handleSlashSelect,
|
||||
]
|
||||
)
|
||||
|
||||
const bottomPaddingClass = "pb-10"
|
||||
const topPaddingClass = hasAttachments ? "pt-10" : ""
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{slashMenuOpen && filteredSlashCommands.length > 0 && (
|
||||
<SlashCommandMenu
|
||||
commands={filteredSlashCommands}
|
||||
selectedIndex={slashSelectedIndex}
|
||||
onSelect={handleSlashSelect}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => (composingRef.current = true)}
|
||||
onCompositionEnd={() => (composingRef.current = false)}
|
||||
onFocus={onFocus}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"text-sm pr-12 resize-none bg-transparent",
|
||||
topPaddingClass,
|
||||
bottomPaddingClass,
|
||||
className
|
||||
)}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{hasAttachments && (
|
||||
<div className="absolute left-2 right-2 top-2">
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.path}
|
||||
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<FileSearch className="h-3 w-3" />
|
||||
<span className="max-w-40 truncate">{attachment.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(attachment.path)}
|
||||
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 right-24 bottom-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
<Button
|
||||
onClick={handlePickFiles}
|
||||
disabled={disabled || isPrompting}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
title="Attach files"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
{showConfigLoading && (
|
||||
<SelectorLoadingChip label="Loading settings..." />
|
||||
)}
|
||||
{hasConfigOptions &&
|
||||
availableConfigOptions.map((option) => (
|
||||
<SessionConfigSelector
|
||||
key={option.id}
|
||||
option={option}
|
||||
onSelect={(configId, valueId) =>
|
||||
onConfigOptionChange?.(configId, valueId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{showModeLoading && <SelectorLoadingChip label="Loading mode..." />}
|
||||
{showModeSelector && effectiveModeId && (
|
||||
<ModeSelector
|
||||
modes={availableModes}
|
||||
selectedModeId={effectiveModeId}
|
||||
onSelect={handleModeSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isPrompting && onCancel ? (
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="Cancel"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !hasSendableContent}
|
||||
size="icon"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="Send"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
||||
import type { SessionModeInfo } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModeSelectorProps {
|
||||
modes: SessionModeInfo[]
|
||||
selectedModeId: string | null
|
||||
onSelect: (modeId: string) => void
|
||||
}
|
||||
|
||||
export function ModeSelector({
|
||||
modes,
|
||||
selectedModeId,
|
||||
onSelect,
|
||||
}: ModeSelectorProps) {
|
||||
const selectedMode = modes.find((m) => m.id === selectedModeId)
|
||||
const label = selectedMode?.name ?? "Mode"
|
||||
const isActive = Boolean(selectedMode)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn("gap-1", isActive && "text-primary")}
|
||||
title={selectedMode?.description ?? selectedMode?.name}
|
||||
>
|
||||
{label}
|
||||
<ChevronUp className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="min-w-72">
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedModeId ?? ""}
|
||||
onValueChange={onSelect}
|
||||
>
|
||||
{modes.map((mode) => (
|
||||
<DropdownMenuRadioItem key={mode.id} value={mode.id}>
|
||||
<DropdownRadioItemContent
|
||||
label={mode.name}
|
||||
description={mode.description}
|
||||
/>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
FilePenLine,
|
||||
ListTodo,
|
||||
Compass,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block"
|
||||
import type { PendingPermission } from "@/contexts/acp-connections-context"
|
||||
import { parsePermissionToolCall } from "@/lib/permission-request"
|
||||
|
||||
interface PermissionDialogProps {
|
||||
permission: PendingPermission | null
|
||||
onRespond: (requestId: string, optionId: string) => void
|
||||
}
|
||||
|
||||
function formatKindLabel(kind: string): string {
|
||||
const normalized = kind.replace(/_/g, " ").trim()
|
||||
return normalized.length > 0 ? normalized : "tool"
|
||||
}
|
||||
|
||||
export function PermissionDialog({
|
||||
permission,
|
||||
onRespond,
|
||||
}: PermissionDialogProps) {
|
||||
const parsed = useMemo(
|
||||
() => parsePermissionToolCall(permission?.tool_call),
|
||||
[permission?.tool_call]
|
||||
)
|
||||
if (!permission) return null
|
||||
|
||||
const hasFileChanges = parsed.fileChanges.length > 0
|
||||
const hasPlan =
|
||||
parsed.planEntries.length > 0 || Boolean(parsed.planExplanation)
|
||||
const hasStructured =
|
||||
Boolean(parsed.command) ||
|
||||
hasFileChanges ||
|
||||
hasPlan ||
|
||||
Boolean(parsed.modeTarget)
|
||||
|
||||
return (
|
||||
<div className="mx-4 mb-3 rounded-xl border border-border/70 bg-card/95 p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="truncate">{parsed.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agent requests permission to continue this turn.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{formatKindLabel(parsed.normalizedKind)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-[min(36vh,18rem)] space-y-2 overflow-y-auto pr-1">
|
||||
{parsed.command && (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span>Command</span>
|
||||
</div>
|
||||
<CodeBlock code={parsed.command} language="bash" />
|
||||
{parsed.cwd && (
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
CWD: {parsed.cwd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFileChanges && (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<FilePenLine className="h-3.5 w-3.5" />
|
||||
<span>Files: {parsed.fileChanges.length}</span>
|
||||
{(parsed.additions > 0 || parsed.deletions > 0) && (
|
||||
<span>
|
||||
+{parsed.additions} / -{parsed.deletions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
||||
{parsed.fileChanges.slice(0, 8).map((change, index) => (
|
||||
<div
|
||||
key={`${change.path}-${index}`}
|
||||
className="break-all font-mono text-xs text-foreground/90"
|
||||
>
|
||||
{change.path}
|
||||
</div>
|
||||
))}
|
||||
{parsed.fileChanges.length > 8 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{parsed.fileChanges.length - 8} more files
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{parsed.diffPreview && (
|
||||
<CodeBlock code={parsed.diffPreview} language="diff" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPlan && (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ListTodo className="h-3.5 w-3.5" />
|
||||
<span>Plan</span>
|
||||
</div>
|
||||
{parsed.planExplanation && (
|
||||
<p className="text-xs text-foreground/90">
|
||||
{parsed.planExplanation}
|
||||
</p>
|
||||
)}
|
||||
{parsed.planEntries.length > 0 && (
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
||||
{parsed.planEntries.map((entry, index) => (
|
||||
<div key={`${entry.text}-${index}`} className="text-xs">
|
||||
<span className="text-foreground/90">{entry.text}</span>
|
||||
{entry.status && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({entry.status})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.modeTarget && (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 p-2 text-xs">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Compass className="h-3.5 w-3.5" />
|
||||
<span>Target mode: {parsed.modeTarget}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasStructured && (
|
||||
<pre className="rounded-md border border-border/60 bg-muted/20 p-2 text-xs whitespace-pre-wrap break-all text-foreground/90">
|
||||
{parsed.jsonPreview}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{permission.options.map((opt) => {
|
||||
const isReject = opt.kind.startsWith("reject")
|
||||
return (
|
||||
<Button
|
||||
key={opt.option_id}
|
||||
variant={isReject ? "outline" : "default"}
|
||||
className="h-auto min-h-9 whitespace-normal break-words text-left"
|
||||
onClick={() => onRespond(permission.request_id, opt.option_id)}
|
||||
>
|
||||
{opt.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment } from "react"
|
||||
import { ChevronUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
||||
import type { SessionConfigOptionInfo } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SessionConfigSelectorProps {
|
||||
option: SessionConfigOptionInfo
|
||||
onSelect: (configId: string, valueId: string) => void
|
||||
}
|
||||
|
||||
export function SessionConfigSelector({
|
||||
option,
|
||||
onSelect,
|
||||
}: SessionConfigSelectorProps) {
|
||||
if (option.kind.type !== "select") return null
|
||||
|
||||
const selected = option.kind.options.find(
|
||||
(item) => item.value === option.kind.current_value
|
||||
)
|
||||
const label = selected?.name ?? option.kind.current_value
|
||||
const isActive = Boolean(selected)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn("gap-1 shrink-0", isActive && "text-primary")}
|
||||
title={option.description ?? option.name}
|
||||
>
|
||||
{label}
|
||||
<ChevronUp className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="min-w-72">
|
||||
<DropdownMenuRadioGroup
|
||||
value={option.kind.current_value}
|
||||
onValueChange={(value) => onSelect(option.id, value)}
|
||||
>
|
||||
{option.kind.groups.length > 0
|
||||
? option.kind.groups.map((group, index) => (
|
||||
<Fragment key={group.group}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{group.name}</DropdownMenuLabel>
|
||||
{group.options.map((item) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={`${group.group}-${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<DropdownRadioItemContent
|
||||
label={item.name}
|
||||
description={item.description}
|
||||
/>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</Fragment>
|
||||
))
|
||||
: option.kind.options.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value}>
|
||||
<DropdownRadioItemContent
|
||||
label={item.name}
|
||||
description={item.description}
|
||||
/>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { AvailableCommandInfo } from "@/lib/types"
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
commands: AvailableCommandInfo[]
|
||||
selectedIndex: number
|
||||
onSelect: (command: AvailableCommandInfo) => void
|
||||
}
|
||||
|
||||
export function SlashCommandMenu({
|
||||
commands,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: SlashCommandMenuProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[selectedIndex] as
|
||||
| HTMLElement
|
||||
| undefined
|
||||
el?.scrollIntoView({ block: "nearest" })
|
||||
}, [selectedIndex])
|
||||
|
||||
if (commands.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 z-50 max-h-48 overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg"
|
||||
>
|
||||
{commands.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||
i === selectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
onSelect(cmd)
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-primary">/{cmd.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{cmd.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { MessageInput } from "@/components/chat/message-input"
|
||||
import type { AgentType, PromptDraft, SessionStats } from "@/lib/types"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import { useAcpActions } from "@/contexts/acp-connections-context"
|
||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||
import {
|
||||
adaptLiveMessageFromAcp,
|
||||
adaptMessageTurns,
|
||||
} from "@/lib/adapters/ai-elements-adapter"
|
||||
import {
|
||||
buildUserMessageTextPartsFromDraft,
|
||||
extractUserResourcesFromDraft,
|
||||
getPromptDraftDisplayText,
|
||||
} from "@/lib/prompt-draft"
|
||||
import {
|
||||
buildPlanKey,
|
||||
extractLatestPlanEntriesFromMessages,
|
||||
} from "@/lib/agent-plan"
|
||||
import {
|
||||
buildConversationDraftStorageKey,
|
||||
buildNewConversationDraftStorageKey,
|
||||
moveMessageInputDraft,
|
||||
} from "@/lib/message-input-draft"
|
||||
import {
|
||||
createConversation,
|
||||
getFolderConversation,
|
||||
openSettingsWindow,
|
||||
updateConversationStatus,
|
||||
updateConversationExternalId,
|
||||
} from "@/lib/tauri"
|
||||
import { AgentSelector } from "@/components/chat/agent-selector"
|
||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||
import { LiveTurnStats } from "@/components/message/live-turn-stats"
|
||||
import { TurnStats } from "@/components/message/turn-stats"
|
||||
import { UserResourceLinks } from "@/components/message/user-resource-links"
|
||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||
import {
|
||||
MessageThread,
|
||||
MessageThreadContent,
|
||||
} from "@/components/ai-elements/message-thread"
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
|
||||
|
||||
interface WelcomeInputPanelProps {
|
||||
defaultAgentType?: AgentType
|
||||
workingDir?: string
|
||||
tabId?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function isExpectedAutoLinkError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false
|
||||
return (error as { alerted?: unknown }).alerted === true
|
||||
}
|
||||
|
||||
function buildInlineAutoConnectErrorMessage(raw: string): string {
|
||||
const normalized = raw.trim().replace(/[。.!?,,;;::]+$/u, "")
|
||||
if (!normalized) return "点击前往设置 > Agents 管理安装。"
|
||||
const hasSdkNotInstalled = /SDK\s*尚未安装$/u.test(normalized)
|
||||
const message =
|
||||
!hasSdkNotInstalled && normalized.endsWith("尚未安装")
|
||||
? normalized.replace(/尚未安装$/u, "SDK 尚未安装")
|
||||
: normalized
|
||||
if (message.includes("设置 > Agents 管理安装")) {
|
||||
return `${message}。`
|
||||
}
|
||||
return `${message},点击前往设置 > Agents 管理安装。`
|
||||
}
|
||||
|
||||
export function WelcomeInputPanel({
|
||||
defaultAgentType,
|
||||
workingDir,
|
||||
tabId,
|
||||
isActive = true,
|
||||
}: WelcomeInputPanelProps) {
|
||||
const fallbackContextId = useMemo(() => crypto.randomUUID(), [])
|
||||
const contextKey = tabId ?? `new-${fallbackContextId}`
|
||||
|
||||
const { folderId, refreshConversations } = useFolderContext()
|
||||
const { promoteNewConversationTab, linkTabConversation } = useTabContext()
|
||||
const { setSessionStats } = useSessionStats()
|
||||
const { migrateContextKey } = useAcpActions()
|
||||
const latestSessionStatsRef = useRef<SessionStats | null>(null)
|
||||
const isActiveRef = useRef(isActive)
|
||||
const statsRefreshSeqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
isActiveRef.current = isActive
|
||||
}, [isActive])
|
||||
|
||||
// Reset or restore token stats when tab becomes active
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setSessionStats(latestSessionStatsRef.current)
|
||||
}
|
||||
}, [isActive, setSessionStats])
|
||||
|
||||
const applySessionStats = useCallback(
|
||||
(stats: SessionStats | null) => {
|
||||
latestSessionStatsRef.current = stats
|
||||
if (isActiveRef.current) {
|
||||
setSessionStats(stats)
|
||||
}
|
||||
},
|
||||
[setSessionStats]
|
||||
)
|
||||
|
||||
const hasTokenStats = useCallback((stats: SessionStats | null): boolean => {
|
||||
if (!stats) return false
|
||||
return (
|
||||
stats.total_usage !== null ||
|
||||
stats.total_tokens != null ||
|
||||
stats.context_window_used_tokens != null ||
|
||||
stats.context_window_max_tokens != null
|
||||
)
|
||||
}, [])
|
||||
|
||||
const hasAssistantUsage = useCallback(
|
||||
(messages: AdaptedMessage[]): boolean => {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i]
|
||||
if (message.role !== "assistant") continue
|
||||
return message.usage != null
|
||||
}
|
||||
return false
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const refreshConversationFromDb = useCallback(
|
||||
async (expectedTurnCount?: number) => {
|
||||
const conversationId = dbConvIdRef.current
|
||||
if (!conversationId) return
|
||||
|
||||
const refreshSeq = ++statsRefreshSeqRef.current
|
||||
const maxAttempts = 10
|
||||
const retryDelayMs = 400
|
||||
let latestMessages: AdaptedMessage[] | null = null
|
||||
let latestStats: SessionStats | null = null
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
||||
|
||||
try {
|
||||
const detail = await getFolderConversation(conversationId)
|
||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
||||
|
||||
const messages = adaptMessageTurns(detail.turns)
|
||||
const stats = detail.session_stats ?? null
|
||||
latestMessages = messages
|
||||
latestStats = stats
|
||||
|
||||
const hasExpectedTurns =
|
||||
expectedTurnCount == null ||
|
||||
detail.turns.length >= expectedTurnCount
|
||||
const canShowTurnTokenStats = hasAssistantUsage(messages)
|
||||
const canShowSessionTokenStats = hasTokenStats(stats)
|
||||
if (
|
||||
hasExpectedTurns &&
|
||||
(canShowTurnTokenStats || canShowSessionTokenStats)
|
||||
) {
|
||||
setHistory(messages)
|
||||
if (canShowSessionTokenStats) {
|
||||
applySessionStats(stats)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient read failures while session file is syncing.
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
||||
if (latestMessages) {
|
||||
setHistory(latestMessages)
|
||||
}
|
||||
if (latestStats && hasTokenStats(latestStats)) {
|
||||
applySessionStats(latestStats)
|
||||
}
|
||||
},
|
||||
[applySessionStats, hasAssistantUsage, hasTokenStats]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
statsRefreshSeqRef.current += 1
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [phase, setPhase] = useState<"welcome" | "conversation">("welcome")
|
||||
const [selectedAgent, setSelectedAgent] = useState<AgentType>(
|
||||
defaultAgentType ?? "codex"
|
||||
)
|
||||
const [history, setHistory] = useState<AdaptedMessage[]>([])
|
||||
const historyRef = useRef<AdaptedMessage[]>([])
|
||||
useEffect(() => {
|
||||
historyRef.current = history
|
||||
}, [history])
|
||||
const historicalPlanEntries = useMemo(
|
||||
() => extractLatestPlanEntriesFromMessages(history),
|
||||
[history]
|
||||
)
|
||||
const historicalPlanKey = useMemo(
|
||||
() => buildPlanKey(historicalPlanEntries),
|
||||
[historicalPlanEntries]
|
||||
)
|
||||
const [modeId, setModeId] = useState<string | null>(null)
|
||||
const [dbConversationId, setDbConversationId] = useState<number | null>(null)
|
||||
const [agentsLoaded, setAgentsLoaded] = useState(false)
|
||||
const [usableAgentCount, setUsableAgentCount] = useState(0)
|
||||
const [agentConnectError, setAgentConnectError] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const canAutoConnect = agentsLoaded && usableAgentCount > 0
|
||||
const pendingPromptRef = useRef<{
|
||||
draft: PromptDraft
|
||||
modeId: string | null
|
||||
} | null>(null)
|
||||
const newConversationDraftStorageKey = useMemo(
|
||||
() =>
|
||||
buildNewConversationDraftStorageKey({
|
||||
folderId,
|
||||
}),
|
||||
[folderId]
|
||||
)
|
||||
const activeDraftStorageKey = useMemo(() => {
|
||||
if (dbConversationId != null) {
|
||||
return buildConversationDraftStorageKey(selectedAgent, dbConversationId)
|
||||
}
|
||||
return newConversationDraftStorageKey
|
||||
}, [dbConversationId, newConversationDraftStorageKey, selectedAgent])
|
||||
|
||||
// DB persistence state
|
||||
const dbConvIdRef = useRef<number | null>(null)
|
||||
const statusUpdatedRef = useRef(false)
|
||||
const tabPromotedRef = useRef(false)
|
||||
const tabIdRef = useRef(tabId)
|
||||
const selectedAgentRef = useRef(selectedAgent)
|
||||
const convTitleRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
tabIdRef.current = tabId
|
||||
}, [tabId])
|
||||
useEffect(() => {
|
||||
selectedAgentRef.current = selectedAgent
|
||||
}, [selectedAgent])
|
||||
|
||||
const {
|
||||
conn,
|
||||
modeLoading,
|
||||
configOptionsLoading,
|
||||
autoConnectError,
|
||||
handleFocus,
|
||||
handleSend: lifecycleSend,
|
||||
handleSetConfigOption,
|
||||
handleCancel,
|
||||
handleRespondPermission,
|
||||
} = useConnectionLifecycle({
|
||||
contextKey,
|
||||
agentType: selectedAgent,
|
||||
isActive: isActive && canAutoConnect,
|
||||
workingDir,
|
||||
})
|
||||
|
||||
// Destructure stable callback + volatile status separately.
|
||||
// conn.connect is stable (depends only on actions + contextKey).
|
||||
// conn.status changes on state transitions (~5/turn), NOT on every
|
||||
// streaming delta (hundreds/sec) — much cheaper than depending on `conn`.
|
||||
const {
|
||||
status: connStatus,
|
||||
connect: connConnect,
|
||||
disconnect: connDisconnect,
|
||||
sessionId: connSessionId,
|
||||
} = conn
|
||||
const connectionModes = useMemo(
|
||||
() => conn.modes?.available_modes ?? [],
|
||||
[conn.modes?.available_modes]
|
||||
)
|
||||
const connectionConfigOptions = useMemo(
|
||||
() => conn.configOptions ?? [],
|
||||
[conn.configOptions]
|
||||
)
|
||||
const connectionCommands = useMemo(
|
||||
() => conn.availableCommands ?? [],
|
||||
[conn.availableCommands]
|
||||
)
|
||||
const selectedModeId = useMemo(() => {
|
||||
if (connectionModes.length === 0) return null
|
||||
if (modeId && connectionModes.some((mode) => mode.id === modeId)) {
|
||||
return modeId
|
||||
}
|
||||
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
|
||||
}, [conn.modes?.current_mode_id, connectionModes, modeId])
|
||||
|
||||
// Persist the agent-assigned session ID as external_id once both
|
||||
// the DB conversation ID and the ACP session ID are available.
|
||||
const externalIdSavedRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (connSessionId) {
|
||||
sessionIdRef.current = connSessionId
|
||||
}
|
||||
}, [connSessionId])
|
||||
|
||||
const trySaveExternalId = useCallback(() => {
|
||||
if (
|
||||
externalIdSavedRef.current ||
|
||||
!dbConvIdRef.current ||
|
||||
!sessionIdRef.current
|
||||
)
|
||||
return
|
||||
externalIdSavedRef.current = true
|
||||
updateConversationExternalId(
|
||||
dbConvIdRef.current,
|
||||
sessionIdRef.current
|
||||
).catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] update external_id:", e)
|
||||
)
|
||||
}, [])
|
||||
|
||||
// Trigger when session ID arrives from ACP
|
||||
useEffect(() => {
|
||||
if (connSessionId) trySaveExternalId()
|
||||
}, [connSessionId, trySaveExternalId])
|
||||
|
||||
const isConnecting =
|
||||
connStatus === "connecting" || connStatus === "downloading"
|
||||
|
||||
const prevStatusRef = useRef(connStatus)
|
||||
|
||||
// Accumulate history when prompting completes
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = connStatus
|
||||
|
||||
if (prev === "prompting" && connStatus !== "prompting") {
|
||||
if (conn.liveMessage && conn.liveMessage.content.length > 0) {
|
||||
const adapted = adaptLiveMessageFromAcp(conn.liveMessage, {
|
||||
isLiveStreaming: false,
|
||||
})
|
||||
|
||||
setHistory((h) => [...h, adapted])
|
||||
}
|
||||
// Agent turn ended — mark as pending_review unless it's a terminal state
|
||||
if (
|
||||
dbConvIdRef.current &&
|
||||
connStatus !== "disconnected" &&
|
||||
connStatus !== "error"
|
||||
) {
|
||||
updateConversationStatus(dbConvIdRef.current, "pending_review")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
}
|
||||
|
||||
void refreshConversationFromDb(
|
||||
historyRef.current.length + (conn.liveMessage ? 1 : 0)
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- conn.liveMessage, lifecycleSend intentionally omitted: effect only fires on status transitions
|
||||
}, [connStatus, refreshConversations, refreshConversationFromDb])
|
||||
|
||||
// When connection becomes "connected" and we have a pending prompt, send it
|
||||
useEffect(() => {
|
||||
if (connStatus === "connected" && pendingPromptRef.current) {
|
||||
const pending = pendingPromptRef.current
|
||||
pendingPromptRef.current = null
|
||||
lifecycleSend(pending.draft, pending.modeId)
|
||||
}
|
||||
}, [connStatus, lifecycleSend])
|
||||
|
||||
// Promote tab helper — call once when conversation ends or component unmounts
|
||||
const promoteTab = useCallback(() => {
|
||||
if (tabPromotedRef.current || !dbConvIdRef.current) return
|
||||
tabPromotedRef.current = true
|
||||
const tid = tabIdRef.current
|
||||
const convId = dbConvIdRef.current
|
||||
const agent = selectedAgentRef.current
|
||||
const title = convTitleRef.current || "Untitled"
|
||||
const canonicalContextKey = `conv-${agent}-${convId}`
|
||||
|
||||
// Keep in-flight stream/state attached when this new-conversation view
|
||||
// is closed and later reopened as a canonical conversation tab.
|
||||
migrateContextKey(contextKey, canonicalContextKey)
|
||||
|
||||
if (tid) {
|
||||
promoteNewConversationTab(tid, convId, agent, title)
|
||||
}
|
||||
refreshConversations()
|
||||
}, [
|
||||
promoteNewConversationTab,
|
||||
refreshConversations,
|
||||
migrateContextKey,
|
||||
contextKey,
|
||||
])
|
||||
|
||||
// Update conversation status on disconnect/error + promote tab
|
||||
useEffect(() => {
|
||||
if (!dbConvIdRef.current || statusUpdatedRef.current) return
|
||||
if (connStatus === "disconnected") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(dbConvIdRef.current, "completed").catch((e) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
promoteTab()
|
||||
} else if (connStatus === "error") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(dbConvIdRef.current, "cancelled").catch((e) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
promoteTab()
|
||||
}
|
||||
}, [connStatus, promoteTab])
|
||||
|
||||
// Promote tab on unmount if not yet promoted (e.g. user closes tab)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
promoteTab()
|
||||
}
|
||||
}, [promoteTab])
|
||||
|
||||
const handleAgentSelect = useCallback(
|
||||
(agentType: AgentType) => {
|
||||
if (agentType === selectedAgent) return
|
||||
setSelectedAgent(agentType)
|
||||
setModeId(null)
|
||||
setAgentConnectError(null)
|
||||
connDisconnect()
|
||||
.catch((e) => console.error("[WelcomePanel] disconnect old agent:", e))
|
||||
.finally(() => {
|
||||
connConnect(agentType, workingDir, undefined, {
|
||||
source: "auto_link",
|
||||
})
|
||||
.then(() => {
|
||||
setAgentConnectError(null)
|
||||
})
|
||||
.catch((e) => {
|
||||
setAgentConnectError(normalizeErrorMessage(e))
|
||||
if (!isExpectedAutoLinkError(e)) {
|
||||
console.error("[WelcomePanel] switch agent:", e)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
[selectedAgent, connConnect, connDisconnect, workingDir]
|
||||
)
|
||||
|
||||
// Welcome phase: submit first message.
|
||||
const handleWelcomeSend = useCallback(
|
||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
||||
const displayText = getPromptDraftDisplayText(draft)
|
||||
const userMsg: AdaptedMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: buildUserMessageTextPartsFromDraft(draft),
|
||||
userResources: extractUserResourcesFromDraft(draft),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
setHistory([userMsg])
|
||||
setPhase("conversation")
|
||||
applySessionStats(null)
|
||||
statsRefreshSeqRef.current += 1
|
||||
|
||||
// If already connected, send directly; otherwise queue for when connected
|
||||
if (connStatus === "connected") {
|
||||
lifecycleSend(draft, selectedModeId)
|
||||
} else {
|
||||
pendingPromptRef.current = {
|
||||
draft,
|
||||
modeId: selectedModeId ?? null,
|
||||
}
|
||||
// Ensure connection is being established
|
||||
if (
|
||||
!connStatus ||
|
||||
connStatus === "disconnected" ||
|
||||
connStatus === "error"
|
||||
) {
|
||||
connConnect(selectedAgent, workingDir, undefined, {
|
||||
source: "auto_link",
|
||||
}).catch((e) => {
|
||||
setAgentConnectError(normalizeErrorMessage(e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DB persistence: create conversation
|
||||
const title = displayText.slice(0, 80)
|
||||
convTitleRef.current = title
|
||||
createConversation(folderId, selectedAgent, title)
|
||||
.then((convId) => {
|
||||
dbConvIdRef.current = convId
|
||||
setDbConversationId(convId)
|
||||
moveMessageInputDraft(
|
||||
newConversationDraftStorageKey,
|
||||
buildConversationDraftStorageKey(selectedAgent, convId)
|
||||
)
|
||||
// Link tab to DB conversation so status dot updates and tab is persisted
|
||||
if (tabIdRef.current) {
|
||||
linkTabConversation(tabIdRef.current, convId, selectedAgent, title)
|
||||
}
|
||||
// If ACP session ID already arrived, save external_id now
|
||||
trySaveExternalId()
|
||||
refreshConversations()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] create conversation:", e)
|
||||
)
|
||||
},
|
||||
[
|
||||
selectedAgent,
|
||||
workingDir,
|
||||
connStatus,
|
||||
connConnect,
|
||||
lifecycleSend,
|
||||
folderId,
|
||||
refreshConversations,
|
||||
linkTabConversation,
|
||||
trySaveExternalId,
|
||||
applySessionStats,
|
||||
newConversationDraftStorageKey,
|
||||
]
|
||||
)
|
||||
|
||||
// Conversation phase: prepend user message to history before sending
|
||||
const handleSendWithHistory = useCallback(
|
||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
||||
const userMsg: AdaptedMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: buildUserMessageTextPartsFromDraft(draft),
|
||||
userResources: extractUserResourcesFromDraft(draft),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
setHistory((h) => [...h, userMsg])
|
||||
lifecycleSend(draft, selectedModeId)
|
||||
|
||||
// Update status
|
||||
if (dbConvIdRef.current) {
|
||||
updateConversationStatus(dbConvIdRef.current, "in_progress")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
statusUpdatedRef.current = false
|
||||
}
|
||||
},
|
||||
[lifecycleSend, refreshConversations]
|
||||
)
|
||||
|
||||
const handleOpenAgentsSettings = useCallback(() => {
|
||||
openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => {
|
||||
console.error("[WelcomePanel] failed to open settings window:", err)
|
||||
})
|
||||
}, [selectedAgent])
|
||||
|
||||
// Track live message visibility across turn completion.
|
||||
// Hooks must be called before any conditional returns.
|
||||
const prevConnStatusForLiveRef = useRef(connStatus)
|
||||
const showLiveTransitionRef = useRef(false)
|
||||
const prevHistoryLenRef = useRef(history.length)
|
||||
|
||||
if (connStatus === "prompting") {
|
||||
showLiveTransitionRef.current = false
|
||||
} else if (prevConnStatusForLiveRef.current === "prompting") {
|
||||
showLiveTransitionRef.current = true
|
||||
}
|
||||
prevConnStatusForLiveRef.current = connStatus
|
||||
|
||||
// Once the effect adds the adapted message to history, hide the live block.
|
||||
if (
|
||||
history.length > prevHistoryLenRef.current &&
|
||||
showLiveTransitionRef.current
|
||||
) {
|
||||
showLiveTransitionRef.current = false
|
||||
}
|
||||
prevHistoryLenRef.current = history.length
|
||||
|
||||
// ── Welcome phase ──
|
||||
if (phase === "welcome") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4">
|
||||
<div className="w-full max-w-2xl space-y-6">
|
||||
<AgentSelector
|
||||
defaultAgentType={selectedAgent}
|
||||
onSelect={handleAgentSelect}
|
||||
onAgentsLoaded={(agents) => {
|
||||
setAgentsLoaded(true)
|
||||
setUsableAgentCount(
|
||||
agents.filter((agent) => agent.enabled && agent.available)
|
||||
.length
|
||||
)
|
||||
}}
|
||||
onOpenAgentsSettings={handleOpenAgentsSettings}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
|
||||
{autoConnectError || agentConnectError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAgentsSettings}
|
||||
className="w-full cursor-pointer rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-center text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
{(() => {
|
||||
const inlineMessage = buildInlineAutoConnectErrorMessage(
|
||||
autoConnectError ?? agentConnectError ?? ""
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap text-center"
|
||||
title={inlineMessage}
|
||||
>
|
||||
{inlineMessage}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<MessageInput
|
||||
key={newConversationDraftStorageKey}
|
||||
onSend={handleWelcomeSend}
|
||||
defaultPath={workingDir}
|
||||
placeholder={
|
||||
agentsLoaded && usableAgentCount === 0
|
||||
? "请先启用至少一个 Agent 后开始会话..."
|
||||
: "Ask anything..."
|
||||
}
|
||||
autoFocus
|
||||
attachmentTabId={tabId ?? null}
|
||||
modes={connectionModes}
|
||||
configOptions={connectionConfigOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={setModeId}
|
||||
onConfigOptionChange={handleSetConfigOption}
|
||||
availableCommands={connectionCommands}
|
||||
disabled={!canAutoConnect || isConnecting}
|
||||
className="min-h-28 max-h-60"
|
||||
draftStorageKey={newConversationDraftStorageKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Conversation phase ──
|
||||
|
||||
const showLive = Boolean(
|
||||
conn.liveMessage &&
|
||||
(connStatus === "prompting" ||
|
||||
(conn.liveMessage.content.length > 0 && showLiveTransitionRef.current))
|
||||
)
|
||||
|
||||
return (
|
||||
<ConversationShell
|
||||
status={connStatus}
|
||||
defaultPath={workingDir}
|
||||
error={conn.error}
|
||||
pendingPermission={conn.pendingPermission}
|
||||
onFocus={handleFocus}
|
||||
onSend={handleSendWithHistory}
|
||||
onCancel={handleCancel}
|
||||
onRespondPermission={handleRespondPermission}
|
||||
modes={connectionModes}
|
||||
configOptions={connectionConfigOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={setModeId}
|
||||
onConfigOptionChange={handleSetConfigOption}
|
||||
availableCommands={connectionCommands}
|
||||
attachmentTabId={tabId ?? null}
|
||||
draftStorageKey={activeDraftStorageKey}
|
||||
>
|
||||
<div className="relative flex flex-col h-full">
|
||||
<MessageThread className="flex-1 min-h-0">
|
||||
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
|
||||
{history.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
<Message from={msg.role === "tool" ? "assistant" : msg.role}>
|
||||
<MessageContent>
|
||||
<ContentPartsRenderer parts={msg.content} role={msg.role} />
|
||||
</MessageContent>
|
||||
{msg.role === "user" && msg.userResources?.length ? (
|
||||
<UserResourceLinks
|
||||
resources={msg.userResources}
|
||||
className="self-end"
|
||||
/>
|
||||
) : null}
|
||||
</Message>
|
||||
{msg.role === "assistant" && (
|
||||
<TurnStats
|
||||
usage={msg.usage}
|
||||
duration_ms={msg.duration_ms}
|
||||
model={msg.model}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showLive && <LiveMessageBlock message={conn.liveMessage!} />}
|
||||
</MessageThreadContent>
|
||||
</MessageThread>
|
||||
{showLive && <LiveTurnStats message={conn.liveMessage!} />}
|
||||
<AgentPlanOverlay
|
||||
message={conn.liveMessage}
|
||||
entries={historicalPlanEntries}
|
||||
planKey={historicalPlanKey}
|
||||
/>
|
||||
</div>
|
||||
</ConversationShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user