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:
jcfs
2026-01-12 14:21:26 +00:00
parent e012e070a9
commit 8e01191c68
17 changed files with 303 additions and 53 deletions
+18 -6
View File
@@ -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
+2 -1
View File
@@ -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('');
+2 -1
View File
@@ -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,
+1
View File
@@ -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) => {
+192 -16
View File
@@ -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">
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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') {
+16
View File
@@ -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);
});
}
+5 -4
View File
@@ -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 },
+8 -1
View File
@@ -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;
+1 -1
View File
@@ -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",