feat: enhance tool call display with payload details and typing indicator
- Add typing indicator in TaskChatPanel that shows when agent is working - Enhanced ToolCallCard component with expandable details section - Backend now passes tool call args (kind, path, locations, raw_input) to comments - Add generateUUID fallback for non-secure contexts (HTTP non-localhost) - Configure Next.js allowedDevOrigins for cross-origin dev access - Bind dev server to 0.0.0.0 for network access Backend changes: - Extract Kind, Locations, RawInput from ACP ToolCall updates - Add Args field to ToolCallData in watcher - Pass args through CommentCreator interface to comment metadata Frontend changes: - ToolCallCard shows file path, status icons, expandable args - getToolIcon matches ACP ToolKind values (read, edit, search, execute) - Typing indicator with animated dots when awaiting agent response - Replace crypto.randomUUID with fallback utility across all files
This commit is contained in:
@@ -685,18 +685,30 @@ func (a *commentCreatorAdapter) CreateAgentComment(ctx context.Context, taskID,
|
||||
}
|
||||
|
||||
// CreateToolCallComment creates a comment for a tool call with type="tool_call"
|
||||
func (a *commentCreatorAdapter) CreateToolCallComment(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string) error {
|
||||
func (a *commentCreatorAdapter) CreateToolCallComment(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string, args map[string]interface{}) error {
|
||||
metadata := map[string]interface{}{
|
||||
"tool_call_id": toolCallID,
|
||||
"title": title,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
// Add args if provided (contains kind, path, locations, raw_input)
|
||||
if args != nil && len(args) > 0 {
|
||||
metadata["args"] = args
|
||||
|
||||
// Extract kind as tool_name for icon selection in the frontend
|
||||
if kind, ok := args["kind"].(string); ok && kind != "" {
|
||||
metadata["tool_name"] = kind
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.svc.CreateComment(ctx, &taskservice.CreateCommentRequest{
|
||||
TaskID: taskID,
|
||||
Content: title,
|
||||
AuthorType: "agent",
|
||||
AgentSessionID: agentSessionID,
|
||||
Type: "tool_call",
|
||||
Metadata: map[string]interface{}{
|
||||
"tool_call_id": toolCallID,
|
||||
"title": title,
|
||||
"status": status,
|
||||
},
|
||||
Metadata: metadata,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -630,15 +630,46 @@ func (m *Manager) handleSessionNotification(instance *AgentInstance, notificatio
|
||||
toolCallID := string(update.ToolCall.ToolCallId)
|
||||
toolTitle := update.ToolCall.Title
|
||||
toolStatus := string(update.ToolCall.Status)
|
||||
toolKind := string(update.ToolCall.Kind)
|
||||
|
||||
m.logger.Info("tool call started",
|
||||
zap.String("instance_id", instance.ID),
|
||||
zap.String("tool_call_id", toolCallID),
|
||||
zap.String("title", toolTitle))
|
||||
zap.String("title", toolTitle),
|
||||
zap.String("kind", toolKind))
|
||||
m.updateInstanceProgress(instance.ID, 60)
|
||||
|
||||
// Extract args from the tool call for display
|
||||
args := map[string]interface{}{}
|
||||
|
||||
// Add tool kind
|
||||
if toolKind != "" {
|
||||
args["kind"] = toolKind
|
||||
}
|
||||
|
||||
// Add locations (file paths)
|
||||
if len(update.ToolCall.Locations) > 0 {
|
||||
locations := make([]map[string]interface{}, len(update.ToolCall.Locations))
|
||||
for i, loc := range update.ToolCall.Locations {
|
||||
locMap := map[string]interface{}{"path": loc.Path}
|
||||
if loc.Line != nil {
|
||||
locMap["line"] = *loc.Line
|
||||
}
|
||||
locations[i] = locMap
|
||||
}
|
||||
args["locations"] = locations
|
||||
|
||||
// Also set primary path for convenience
|
||||
args["path"] = update.ToolCall.Locations[0].Path
|
||||
}
|
||||
|
||||
// Add raw input if available
|
||||
if update.ToolCall.RawInput != nil {
|
||||
args["raw_input"] = update.ToolCall.RawInput
|
||||
}
|
||||
|
||||
// Publish tool call as a comment so it appears in the chat
|
||||
m.publishToolCall(instance, toolCallID, toolTitle, toolStatus, nil)
|
||||
m.publishToolCall(instance, toolCallID, toolTitle, toolStatus, args)
|
||||
}
|
||||
|
||||
if update.ToolCallUpdate != nil {
|
||||
|
||||
@@ -48,7 +48,7 @@ type InputRequestHandler func(ctx context.Context, taskID, agentID, message stri
|
||||
// CommentCreator is an interface for creating comments on tasks
|
||||
type CommentCreator interface {
|
||||
CreateAgentComment(ctx context.Context, taskID, content, agentSessionID string) error
|
||||
CreateToolCallComment(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string) error
|
||||
CreateToolCallComment(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string, args map[string]interface{}) error
|
||||
}
|
||||
|
||||
// Service is the main orchestrator service
|
||||
@@ -567,7 +567,7 @@ func (s *Service) handleToolCallStarted(ctx context.Context, data watcher.ToolCa
|
||||
// Get the active session ID for this task
|
||||
sessionID, _ := s.executor.GetActiveSessionID(ctx, data.TaskID)
|
||||
|
||||
if err := s.commentCreator.CreateToolCallComment(ctx, data.TaskID, data.ToolCallID, data.Title, data.Status, sessionID); err != nil {
|
||||
if err := s.commentCreator.CreateToolCallComment(ctx, data.TaskID, data.ToolCallID, data.Title, data.Status, sessionID, data.Args); err != nil {
|
||||
s.logger.Error("failed to create tool call comment",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("tool_call_id", data.ToolCallID),
|
||||
|
||||
@@ -42,11 +42,12 @@ type PromptCompleteData struct {
|
||||
|
||||
// ToolCallData contains data from tool_call events
|
||||
type ToolCallData struct {
|
||||
TaskID string `json:"task_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
TaskID string `json:"task_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Args map[string]interface{} `json:"args,omitempty"` // Tool call arguments (kind, path, locations, raw_input)
|
||||
}
|
||||
|
||||
// EventHandlers contains callbacks for different event types
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { AgentCard } from '@/components/settings/agent-card';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { AgentProfile, AgentType } from '@/lib/settings/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
|
||||
{ value: 'claude-code', label: 'Claude Code' },
|
||||
@@ -54,7 +55,7 @@ export default function AgentsSettingsPage() {
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const profile: AgentProfile = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
...newAgent,
|
||||
};
|
||||
setAgents([...agents, profile]);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Environment, EnvironmentType, BaseDocker, AgentType } from '@/lib/settings/types';
|
||||
|
||||
@@ -55,7 +56,7 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id:
|
||||
...environment,
|
||||
envVariables: [
|
||||
...environment.envVariables,
|
||||
{ id: crypto.randomUUID(), key: newEnvKey, value: newEnvValue },
|
||||
{ id: generateUUID(), key: newEnvKey, value: newEnvValue },
|
||||
],
|
||||
});
|
||||
setNewEnvKey('');
|
||||
@@ -76,7 +77,7 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id:
|
||||
...environment,
|
||||
secrets: [
|
||||
...environment.secrets,
|
||||
{ id: crypto.randomUUID(), key: newSecretKey, value: newSecretValue },
|
||||
{ id: generateUUID(), key: newSecretKey, value: newSecretValue },
|
||||
],
|
||||
});
|
||||
setNewSecretKey('');
|
||||
|
||||
@@ -13,6 +13,7 @@ import { EnvironmentCard } from '@/components/settings/environment-card';
|
||||
import { KeyValueInput } from '@/components/settings/key-value-input';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Environment, EnvironmentType, BaseDocker, KeyValue, AgentType } from '@/lib/settings/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export default function EnvironmentsSettingsPage() {
|
||||
const [environments, setEnvironments] = useState<Environment[]>(SETTINGS_DATA.environments);
|
||||
@@ -38,7 +39,7 @@ export default function EnvironmentsSettingsPage() {
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const environment: Environment = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
...newEnv,
|
||||
};
|
||||
setEnvironments([...environments, environment]);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { RepositoryCard } from '@/components/settings/repository-card';
|
||||
import { BoardCard } from '@/components/settings/board-card';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import {
|
||||
createBoardAction,
|
||||
createColumnAction,
|
||||
@@ -272,7 +273,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
const path = selectedRepo?.path || manualValidation.path || manualRepoPath.trim();
|
||||
if (!path) return;
|
||||
const name = selectedRepo?.name || path.split('/').filter(Boolean).slice(-1)[0] || 'New Repository';
|
||||
const draftId = `temp-repo-${crypto.randomUUID()}`;
|
||||
const draftId = `temp-repo-${generateUUID()}`;
|
||||
const draftRepo: RepositoryWithScripts = {
|
||||
id: draftId,
|
||||
workspace_id: currentWorkspace.id,
|
||||
@@ -296,7 +297,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
|
||||
const handleAddRepositoryScript = (repoId: string) => {
|
||||
const script: RepositoryScript = {
|
||||
id: `temp-script-${crypto.randomUUID()}`,
|
||||
id: `temp-script-${generateUUID()}`,
|
||||
repository_id: repoId,
|
||||
name: '',
|
||||
command: '',
|
||||
@@ -431,7 +432,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
|
||||
const handleAddBoard = () => {
|
||||
if (!currentWorkspace) return;
|
||||
const draftBoardId = `temp-${crypto.randomUUID()}`;
|
||||
const draftBoardId = `temp-${generateUUID()}`;
|
||||
const draftBoard: BoardWithColumns = {
|
||||
id: draftBoardId,
|
||||
workspace_id: currentWorkspace.id,
|
||||
@@ -441,7 +442,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
updated_at: '',
|
||||
columns: [
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'Todo',
|
||||
position: 0,
|
||||
@@ -451,7 +452,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'In Progress',
|
||||
position: 1,
|
||||
@@ -461,7 +462,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'To Review',
|
||||
position: 2,
|
||||
@@ -471,7 +472,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'Done',
|
||||
position: 3,
|
||||
@@ -506,7 +507,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
|
||||
column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>
|
||||
) => {
|
||||
const draftColumn: Column = {
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: boardId,
|
||||
name: column.name,
|
||||
position: column.position,
|
||||
|
||||
@@ -258,6 +258,7 @@ export default function TaskPage({ task }: TaskPageClientProps) {
|
||||
agents={AGENTS}
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoadingComments}
|
||||
isAgentWorking={isAgentRunning}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconPlus, IconX } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { KeyValue } from '@/lib/settings/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
type KeyValueInputProps = {
|
||||
items: KeyValue[];
|
||||
@@ -23,7 +24,7 @@ export function KeyValueInput({
|
||||
masked = false,
|
||||
}: KeyValueInputProps) {
|
||||
const handleAdd = () => {
|
||||
onChange([...items, { id: crypto.randomUUID(), key: '', value: '' }]);
|
||||
onChange([...items, { id: generateUUID(), key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { IconBrain, IconListCheck, IconLoader2, IconPaperclip, IconTerminal2 } from '@tabler/icons-react';
|
||||
import {
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCode,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFile,
|
||||
IconListCheck,
|
||||
IconLoader2,
|
||||
IconPaperclip,
|
||||
IconSearch,
|
||||
IconTerminal2,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -29,14 +44,170 @@ type TaskChatPanelProps = {
|
||||
agents: AgentOption[];
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
isAgentWorking?: boolean;
|
||||
};
|
||||
|
||||
export function TaskChatPanel({ taskId, agents, onSend, isLoading }: TaskChatPanelProps) {
|
||||
type ToolCallMetadata = {
|
||||
tool_call_id?: string;
|
||||
tool_name?: string;
|
||||
title?: string;
|
||||
status?: 'pending' | 'running' | 'complete' | 'error';
|
||||
args?: Record<string, unknown>;
|
||||
result?: string;
|
||||
};
|
||||
|
||||
function getToolIcon(toolName?: string) {
|
||||
const name = toolName?.toLowerCase() ?? '';
|
||||
// Match ACP ToolKind values: read, edit, delete, move, search, execute
|
||||
if (name === 'edit' || name.includes('edit') || name.includes('replace') || name.includes('write') || name.includes('save')) {
|
||||
return IconEdit;
|
||||
}
|
||||
if (name === 'read' || name.includes('view') || name.includes('read')) {
|
||||
return IconEye;
|
||||
}
|
||||
if (name === 'search' || name.includes('search') || name.includes('find') || name.includes('retrieval')) {
|
||||
return IconSearch;
|
||||
}
|
||||
if (name === 'execute' || name.includes('terminal') || name.includes('exec') || name.includes('launch') || name.includes('process')) {
|
||||
return IconTerminal2;
|
||||
}
|
||||
if (name === 'delete' || name === 'move' || name.includes('file') || name.includes('create')) {
|
||||
return IconFile;
|
||||
}
|
||||
return IconCode;
|
||||
}
|
||||
|
||||
function getStatusIcon(status?: string) {
|
||||
switch (status) {
|
||||
case 'complete':
|
||||
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
|
||||
case 'error':
|
||||
return <IconX className="h-3.5 w-3.5 text-red-500" />;
|
||||
case 'running':
|
||||
return <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ToolCallCard({ comment }: { comment: Comment }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const metadata = comment.metadata as ToolCallMetadata | undefined;
|
||||
|
||||
const toolName = metadata?.tool_name ?? '';
|
||||
const title = metadata?.title ?? comment.content ?? 'Tool call';
|
||||
const status = metadata?.status;
|
||||
const args = metadata?.args;
|
||||
const result = metadata?.result;
|
||||
|
||||
const ToolIcon = getToolIcon(toolName);
|
||||
const hasDetails = args && Object.keys(args).length > 0;
|
||||
|
||||
// Extract file path from various possible sources
|
||||
let filePath: string | undefined;
|
||||
const rawPath = args?.path ?? args?.file ?? args?.file_path;
|
||||
if (typeof rawPath === 'string') {
|
||||
filePath = rawPath;
|
||||
}
|
||||
// Also try to get path from locations array if available
|
||||
if (!filePath && Array.isArray(args?.locations) && args.locations.length > 0) {
|
||||
const firstLoc = args.locations[0] as { path?: string } | undefined;
|
||||
if (firstLoc?.path) {
|
||||
filePath = firstLoc.path;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/40 bg-muted/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetails && setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
|
||||
hasDetails && 'cursor-pointer hover:bg-muted/40 transition-colors'
|
||||
)}
|
||||
disabled={!hasDetails}
|
||||
>
|
||||
<ToolIcon className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<span className="flex-1 font-mono text-xs text-muted-foreground truncate">
|
||||
{title}
|
||||
</span>
|
||||
{filePath && (
|
||||
<span className="text-xs text-muted-foreground/60 truncate max-w-[200px]">
|
||||
{filePath}
|
||||
</span>
|
||||
)}
|
||||
{getStatusIcon(status)}
|
||||
{hasDetails && (
|
||||
isExpanded
|
||||
? <IconChevronDown className="h-4 w-4 text-muted-foreground/50" />
|
||||
: <IconChevronRight className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && hasDetails && (
|
||||
<div className="border-t border-border/30 bg-background/50 p-3 space-y-2">
|
||||
{args && Object.entries(args).map(([key, value]) => {
|
||||
const strValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
const isLongValue = strValue.length > 100 || strValue.includes('\n');
|
||||
|
||||
return (
|
||||
<div key={key} className="text-xs">
|
||||
<span className="font-medium text-muted-foreground">{key}:</span>
|
||||
{isLongValue ? (
|
||||
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{strValue}
|
||||
</pre>
|
||||
) : (
|
||||
<span className="ml-2 font-mono text-foreground/80">{strValue}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{result && (
|
||||
<div className="text-xs border-t border-border/30 pt-2 mt-2">
|
||||
<span className="font-medium text-muted-foreground">Result:</span>
|
||||
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[150px] overflow-y-auto whitespace-pre-wrap">
|
||||
{result}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 max-w-[85%] rounded-lg bg-muted text-muted-foreground">
|
||||
<div className="flex items-center gap-1" role="status" aria-label="Agent is typing">
|
||||
<span className="text-[11px] uppercase tracking-wide opacity-70 mr-2">Agent</span>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-current animate-bounce"
|
||||
style={{ animationDelay: '0ms', animationDuration: '1s' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-current animate-bounce"
|
||||
style={{ animationDelay: '150ms', animationDuration: '1s' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-current animate-bounce"
|
||||
style={{ animationDelay: '300ms', animationDuration: '1s' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskChatPanel({ taskId, agents, onSend, isLoading, isAgentWorking }: TaskChatPanelProps) {
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? '');
|
||||
const [planModeEnabled, setPlanModeEnabled] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isAwaitingResponse, setIsAwaitingResponse] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const lastAgentMessageCountRef = useRef(0);
|
||||
|
||||
const commentsState = useAppStore((state) => state.comments);
|
||||
const comments = commentsState?.items ?? [];
|
||||
@@ -47,16 +218,31 @@ export function TaskChatPanel({ taskId, agents, onSend, isLoading }: TaskChatPan
|
||||
(c) => c.type === 'message' || c.type === 'content' || c.type === 'tool_call' || !c.type
|
||||
);
|
||||
|
||||
// Scroll to bottom when new comments arrive
|
||||
// Count agent messages to detect new responses
|
||||
const agentMessageCount = visibleComments.filter((c) => c.author_type !== 'user').length;
|
||||
|
||||
// Clear awaiting state when a new agent message arrives
|
||||
useEffect(() => {
|
||||
if (agentMessageCount > lastAgentMessageCountRef.current) {
|
||||
setIsAwaitingResponse(false);
|
||||
}
|
||||
lastAgentMessageCountRef.current = agentMessageCount;
|
||||
}, [agentMessageCount]);
|
||||
|
||||
// Show typing indicator when awaiting response AND agent session is active
|
||||
const showTypingIndicator = isAwaitingResponse && isAgentWorking;
|
||||
|
||||
// Scroll to bottom when new comments arrive or when typing indicator appears
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [visibleComments.length]);
|
||||
}, [visibleComments.length, showTypingIndicator]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const trimmed = messageInput.trim();
|
||||
if (!trimmed || isSending) return;
|
||||
setIsSending(true);
|
||||
setIsAwaitingResponse(true);
|
||||
setMessageInput('');
|
||||
try {
|
||||
await onSend(trimmed);
|
||||
@@ -88,18 +274,7 @@ export function TaskChatPanel({ taskId, agents, onSend, isLoading }: TaskChatPan
|
||||
{visibleComments.map((comment) => {
|
||||
// Tool call comments have a special rendering
|
||||
if (comment.type === 'tool_call') {
|
||||
const metadata = comment.metadata as { title?: string; status?: string } | undefined;
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md bg-muted/30 border border-border/30"
|
||||
>
|
||||
<IconTerminal2 className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{metadata?.title || comment.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return <ToolCallCard key={comment.id} comment={comment} />;
|
||||
}
|
||||
|
||||
// Regular message or agent response - render as markdown
|
||||
@@ -129,6 +304,7 @@ export function TaskChatPanel({ taskId, agents, onSend, isLoading }: TaskChatPan
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showTypingIndicator && <TypingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
|
||||
type ToastVariant = 'default' | 'error';
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const toast = useCallback(
|
||||
(input: ToastInput) => {
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateUUID();
|
||||
const nextToast: Toast = {
|
||||
id,
|
||||
title: input.title,
|
||||
|
||||
@@ -2,7 +2,7 @@ export type AppConfig = {
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:8080';
|
||||
const DEFAULT_API_BASE_URL = 'http://nova:8080';
|
||||
|
||||
export function getBackendConfig(): AppConfig {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -4,3 +4,19 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UUID. Falls back to a custom implementation in non-secure contexts
|
||||
* (e.g., HTTP on non-localhost where crypto.randomUUID is unavailable).
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback implementation
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BackendMessageMap, BackendMessageType } from '@/lib/types/backend';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
type MessageHandler<T extends BackendMessageType> = (message: BackendMessageMap[T]) => void;
|
||||
|
||||
@@ -168,7 +169,7 @@ export class WebSocketClient {
|
||||
}
|
||||
|
||||
request<T>(action: string, payload: unknown, timeoutMs = 5000): Promise<T> {
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
@@ -183,7 +184,7 @@ export class WebSocketClient {
|
||||
this.subscriptions.add(taskId);
|
||||
if (this.status === 'open') {
|
||||
this.send({
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
type: 'request',
|
||||
action: 'task.subscribe',
|
||||
payload: { task_id: taskId },
|
||||
@@ -195,7 +196,7 @@ export class WebSocketClient {
|
||||
this.subscriptions.delete(taskId);
|
||||
if (this.status === 'open') {
|
||||
this.send({
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
type: 'request',
|
||||
action: 'task.unsubscribe',
|
||||
payload: { task_id: taskId },
|
||||
@@ -279,7 +280,7 @@ export class WebSocketClient {
|
||||
// Re-subscribe to all tasks after reconnection
|
||||
this.subscriptions.forEach((taskId) => {
|
||||
this.send({
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
type: 'request',
|
||||
action: 'task.subscribe',
|
||||
payload: { task_id: taskId },
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: [
|
||||
"nova",
|
||||
"nova:3000",
|
||||
"localhost",
|
||||
"localhost:3000",
|
||||
"192.168.1.116",
|
||||
"192.168.1.116:3000",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --hostname 0.0.0.0",
|
||||
"build": "NODE_ENV=production next build",
|
||||
"start": "NODE_ENV=production next start",
|
||||
"lint": "eslint",
|
||||
|
||||
Reference in New Issue
Block a user