Files
CodePilot/src/components/chat/ChatView.tsx
T
7418andClaude Opus 4.6 94528edac9 fix: use local timezone for date-based features (check-in, usage stats)
Root cause: `new Date().toISOString().slice(0, 10)` returns UTC date,
causing date-based features to be off by up to ±1 day for non-UTC users.

Check-in / assistant workspace:
- Add `getLocalDateString()` utility, replace all UTC date comparisons
- Schema v2→v3 migration: normalize lastCheckInDate only when it matches
  UTC today (ambiguous window); leave past dates untouched so check-in
  triggers correctly after upgrade
- Runtime compat in `needsDailyCheckIn`: accept UTC today as fallback
  for edge cases the migration cannot catch
- `needsDailyCheckIn(state, now?)` accepts optional time for testability

Usage stats:
- Replace single-offset SQL bucketing (`DATE(datetime(..., '+N minutes'))`)
  with JS-side per-row aggregation using `getLocalDateString(new Date(utc))`
- Date methods account for historical DST offset at each message's timestamp
- `getTokenUsageStats(days, now?)` accepts optional time to pin window start
- Window boundary via `localDayStartAsUTC()` is already DST-aware

Tests (new file: timezone-boundaries.test.ts, 22 tests):
- getLocalDateString at UTC+9/UTC-5/UTC boundaries with fixed timestamps
- needsDailyCheckIn: compat suppression, clearly-past trigger, local match
- v2→v3 migration: targeted normalization, past-date preservation, null safe
- localDayStartAsUTC: UTC+8/UTC-5 midnight boundaries
- Usage stats bucketing: UTC+9 cross-day, summary window, DST spring-forward
- All usage stats tests use pinned `now` — no time-bomb from fixed test data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:55:29 +08:00

749 lines
31 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { ChatComposerActionBar } from './ChatComposerActionBar';
import { ChatPermissionSelector } from './ChatPermissionSelector';
import { ContextUsageIndicator } from './ContextUsageIndicator';
import { ImageGenToggle } from './ImageGenToggle';
import { usePanel } from '@/hooks/usePanel';
import { getLocalDateString } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { PermissionPrompt } from './PermissionPrompt';
import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen';
import { setLastGeneratedImages, transferPendingToMessage } from '@/lib/image-ref-store';
import {
startStream,
stopStream,
subscribe,
getSnapshot,
getRewindPoints,
respondToPermission,
clearSnapshot,
} from '@/lib/stream-session-manager';
interface ChatViewProps {
sessionId: string;
initialMessages?: Message[];
initialHasMore?: boolean;
modelName?: string;
initialMode?: string;
providerId?: string;
initialPermissionProfile?: 'default' | 'full_access';
}
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId, initialPermissionProfile }: ChatViewProps) {
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const { t } = useTranslation();
const router = useRouter();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>(initialPermissionProfile || 'default');
// Workspace mismatch banner state
const [workspaceMismatchPath, setWorkspaceMismatchPath] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(initialHasMore);
const [loadingMore, setLoadingMore] = useState(false);
const loadingMoreRef = useRef(false);
const [mode, setMode] = useState(initialMode || 'code');
const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
// Effort level selected in MessageInput — lifted here so it enters the stream chain
const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined);
// Thinking mode from app settings
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
// Sync model/provider when session data loads (props update after async fetch)
// Unconditional: when modelName is empty (old session with no saved model),
// fall back to localStorage or default to avoid stale values from previous session.
useEffect(() => {
setCurrentModel(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
}, [modelName]);
useEffect(() => {
setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
}, [providerId]);
// Fetch thinking mode from app settings
useEffect(() => {
fetch('/api/settings/app')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.settings?.thinking_mode) {
setThinkingMode(data.settings.thinking_mode);
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (initialPermissionProfile) {
setPermissionProfile(initialPermissionProfile);
}
}, [initialPermissionProfile]);
// Stream snapshot from the manager — drives all streaming UI
const [streamSnapshot, setStreamSnapshot] = useState<SessionStreamSnapshot | null>(
() => getSnapshot(sessionId)
);
// Derive rendering state from snapshot (backward-compatible with MessageList props)
const isStreaming = streamSnapshot?.phase === 'active';
const streamingContent = streamSnapshot?.streamingContent ?? '';
const toolUses = streamSnapshot?.toolUses ?? [];
const toolResults = streamSnapshot?.toolResults ?? [];
const streamingToolOutput = streamSnapshot?.streamingToolOutput ?? '';
const statusText = streamSnapshot?.statusText;
const pendingPermission = streamSnapshot?.pendingPermission ?? null;
const permissionResolved = streamSnapshot?.permissionResolved ?? null;
const rewindPoints = getRewindPoints(sessionId);
// Pending image generation notices — flushed into the next user message so the LLM knows about generated images
const pendingImageNoticesRef = useRef<string[]>([]);
// Ref for sendMessage to allow self-referencing in timeout auto-retry
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
// Persist mode to database and notify chat list
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode }),
}).then(() => {
window.dispatchEvent(new CustomEvent('session-updated'));
}).catch(() => { /* silent */ });
// Try to switch SDK permission mode in real-time (works if streaming)
fetch('/api/chat/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, mode: newMode }),
}).catch(() => { /* silent — will apply on next message */ });
}
}, [sessionId]);
const handleProviderModelChange = useCallback((newProviderId: string, model: string) => {
setCurrentProviderId(newProviderId);
setCurrentModel(model);
// Persist immediately so switching chats preserves the selection
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, provider_id: newProviderId }),
}).catch(() => {});
}, [sessionId]);
// Subscribe to stream-session-manager for this session.
// On unmount we only unsubscribe — we do NOT abort the stream.
useEffect(() => {
// Restore snapshot if stream is already active (e.g., user switched away and back)
const existing = getSnapshot(sessionId);
if (existing) {
setStreamSnapshot(existing);
if (existing.phase === 'active') {
setStreamingSessionId(sessionId);
}
if (existing.pendingPermission && !existing.permissionResolved) {
setPendingApprovalSessionId(sessionId);
}
// If stream completed while this ChatView was unmounted, consume finalMessageContent now.
// Re-fetch messages from DB to avoid duplicates (backend already persisted the reply).
if (existing.phase !== 'active' && existing.finalMessageContent) {
detectAssistantCompletion(existing.finalMessageContent);
fetch(`/api/chat/sessions/${sessionId}/messages?limit=50`)
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.messages) {
setMessages(data.messages);
}
})
.catch(() => {
// Fallback: append locally if DB fetch fails
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: existing.finalMessageContent!,
created_at: new Date().toISOString(),
token_usage: existing.tokenUsage ? JSON.stringify(existing.tokenUsage) : null,
};
transferPendingToMessage(assistantMessage.id);
setMessages((prev) => [...prev, assistantMessage]);
});
clearSnapshot(sessionId);
}
} else {
setStreamSnapshot(null);
}
const unsubscribe = subscribe(sessionId, (event) => {
setStreamSnapshot(event.snapshot);
// Sync panel state
if (event.type === 'phase-changed') {
if (event.snapshot.phase === 'active') {
setStreamingSessionId(sessionId);
} else {
setStreamingSessionId('');
setPendingApprovalSessionId('');
}
}
if (event.type === 'permission-request') {
setPendingApprovalSessionId(sessionId);
}
if (event.type === 'completed') {
setStreamingSessionId('');
setPendingApprovalSessionId('');
// Append the final assistant message to the messages list
const finalContent = event.snapshot.finalMessageContent;
if (finalContent) {
// Check for assistant project completion signals
detectAssistantCompletion(finalContent);
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: finalContent,
created_at: new Date().toISOString(),
token_usage: event.snapshot.tokenUsage ? JSON.stringify(event.snapshot.tokenUsage) : null,
};
// Transfer pending reference images to this message ID
transferPendingToMessage(assistantMessage.id);
setMessages((prev) => [...prev, assistantMessage]);
}
// Clear the snapshot from the manager since we've consumed it
clearSnapshot(sessionId);
}
});
return () => {
unsubscribe();
// Do NOT abort — stream continues in the manager
};
}, [sessionId, setStreamingSessionId, setPendingApprovalSessionId]);
const initializedRef = useRef(false);
useEffect(() => {
if (initialMessages.length > 0 && !initializedRef.current) {
initializedRef.current = true;
setMessages(initialMessages);
}
}, [initialMessages]);
// Sync mode when session data loads
useEffect(() => {
if (initialMode) {
setMode(initialMode);
}
}, [initialMode]);
// Sync hasMore when initial data loads
useEffect(() => {
setHasMore(initialHasMore);
}, [initialHasMore]);
// Auto-trigger assistant project hooks (onboarding/check-in)
// Uses autoTrigger flag so the backend skips saving user message and title update.
// Works for both fresh sessions (onboarding) and reused sessions (check-in).
const assistantTriggerFiredRef = useRef(false);
const checkAssistantTrigger = useCallback(async () => {
// Don't trigger if already streaming or already triggered in this mount
if (isStreaming || assistantTriggerFiredRef.current) return;
try {
const res = await fetch('/api/settings/workspace');
if (!res.ok) return;
const data = await res.json();
if (!data.path) return;
// Check if this session's working directory matches workspace path
if (workingDirectory !== data.path) return;
const state = data.state;
if (!state) return;
// Check hookTriggeredSessionId: if this session already has a trigger in progress
// AND there are existing messages (conversation started), skip to avoid re-triggering.
// If the session has no messages, the previous trigger may have failed — allow retry.
if (state.hookTriggeredSessionId === sessionId && initialMessages.length > 0) return;
const today = getLocalDateString();
const needsOnboarding = !state.onboardingComplete;
const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today;
if (!needsOnboarding && !needsCheckIn) return;
// For daily check-in, only trigger in the most recent session for this workspace.
// This prevents older sessions from hijacking the check-in when reopened.
if (needsCheckIn) {
const latestRes = await fetch(`/api/workspace/latest-session?workingDirectory=${encodeURIComponent(data.path)}`);
if (latestRes.ok) {
const { sessionId: latestSessionId } = await latestRes.json();
if (latestSessionId && latestSessionId !== sessionId) return;
}
}
// Mark fired so we don't re-trigger on focus/re-render
assistantTriggerFiredRef.current = true;
// Mark in persistent state to prevent duplicate triggers across page reloads
await fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
// Use autoTrigger: the message is invisible (no user bubble, no title update)
const triggerMsg = needsOnboarding
? '请开始助理引导设置。'
: '请开始每日问询。';
startStream({
sessionId,
content: triggerMsg,
mode,
model: currentModel,
providerId: currentProviderId,
autoTrigger: true,
thinking: buildThinkingConfig(),
onModeChanged: (sdkMode) => {
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
},
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
sendMessageRef.current?.(retryContent, retryFiles);
},
});
} catch (e) {
console.error('[ChatView] Assistant auto-trigger failed:', e);
}
}, [sessionId, workingDirectory, isStreaming, mode, currentModel, currentProviderId, handleModeChange]);
useEffect(() => {
// Small delay to let the session fully initialize
const timer = setTimeout(checkAssistantTrigger, 500);
return () => clearTimeout(timer);
}, [checkAssistantTrigger]);
// Detect workspace mismatch: only show banner when this session was previously
// an assistant workspace session (has .assistant data) but the path has since changed.
// Normal project chats should never see this banner.
useEffect(() => {
if (!workingDirectory) return;
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/settings/workspace');
if (!res.ok || cancelled) return;
const data = await res.json();
if (cancelled) return;
if (data.path && workingDirectory !== data.path) {
// Only show banner if this session's workingDirectory was itself an assistant workspace
// (i.e., it has .assistant/state.json). Regular project chats skip the banner.
const inspectRes = await fetch(`/api/workspace/inspect?path=${encodeURIComponent(workingDirectory)}`);
if (!inspectRes.ok || cancelled) return;
const inspectData = await inspectRes.json();
if (inspectData.hasAssistantData) {
setWorkspaceMismatchPath(data.path);
} else {
setWorkspaceMismatchPath(null);
}
} else {
setWorkspaceMismatchPath(null);
}
} catch {
// ignore
}
})();
return () => { cancelled = true; };
}, [workingDirectory]);
// Listen for workspace-switched events (from AssistantWorkspaceSection).
// Only show banner if this session's workingDirectory was the OLD assistant workspace.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.newPath && workingDirectory && workingDirectory === detail.oldPath) {
setWorkspaceMismatchPath(detail.newPath);
}
};
window.addEventListener('assistant-workspace-switched', handler);
return () => window.removeEventListener('assistant-workspace-switched', handler);
}, [workingDirectory]);
const handleOpenNewAssistant = useCallback(async () => {
try {
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
// Prefer reusing the latest assistant session (checkin mode) rather than always creating a new onboarding session
const res = await fetch('/api/workspace/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'checkin', model, provider_id }),
});
if (res.ok) {
const data = await res.json();
window.dispatchEvent(new CustomEvent('session-created'));
router.push(`/chat/${data.session.id}`);
}
} catch (e) {
console.error('[ChatView] Failed to open assistant session:', e);
}
}, [router]);
const loadEarlierMessages = useCallback(async () => {
// Use ref as atomic lock to prevent double-fetch from rapid clicks
if (loadingMoreRef.current || !hasMore || messages.length === 0) return;
loadingMoreRef.current = true;
setLoadingMore(true);
try {
// Use _rowid of the earliest message as cursor
const earliest = messages[0];
const earliestRowId = (earliest as Message & { _rowid?: number })._rowid;
if (!earliestRowId) return;
const res = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100&before=${earliestRowId}`);
if (!res.ok) return;
const data: MessagesResponse = await res.json();
setHasMore(data.hasMore ?? false);
if (data.messages.length > 0) {
setMessages(prev => [...data.messages, ...prev]);
}
} finally {
loadingMoreRef.current = false;
setLoadingMore(false);
}
}, [sessionId, messages, hasMore]);
// Stop streaming — delegates to manager
const stopStreaming = useCallback(() => {
stopStream(sessionId);
}, [sessionId]);
// Permission response — delegates to manager
const handlePermissionResponse = useCallback(
async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, denyMessage?: string) => {
setPendingApprovalSessionId('');
await respondToPermission(sessionId, decision, updatedInput, denyMessage);
},
[sessionId, setPendingApprovalSessionId]
);
// Detect assistant project completion signals in final message content.
// Only processes fences when this session's workingDirectory matches the current assistant workspace path.
const detectAssistantCompletion = useCallback(async (content: string) => {
// Guard: only allow fence processing for the current assistant workspace session
if (!workingDirectory) return;
try {
const wsRes = await fetch('/api/settings/workspace');
if (!wsRes.ok) return;
const wsData = await wsRes.json();
if (!wsData.path || wsData.path !== workingDirectory) return;
} catch {
return;
}
const clearHookTriggered = () =>
fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: '__clear__' }),
}).catch(() => {});
// Check for onboarding completion
const onboardingMatch = content.match(/```onboarding-complete\n([\s\S]*?)\n```/);
if (onboardingMatch) {
try {
const answers = JSON.parse(onboardingMatch[1]);
await fetch('/api/workspace/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers, sessionId }),
});
await clearHookTriggered();
} catch (e) {
console.error('[ChatView] Onboarding completion failed:', e);
}
return;
}
// Check for check-in completion
const checkinMatch = content.match(/```checkin-complete\n([\s\S]*?)\n```/);
if (checkinMatch) {
try {
const answers = JSON.parse(checkinMatch[1]);
await fetch('/api/workspace/checkin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers, sessionId }),
});
await clearHookTriggered();
} catch (e) {
console.error('[ChatView] Check-in completion failed:', e);
}
}
}, [sessionId, workingDirectory]);
// Build SDK thinking config from settings
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
if (thinkingMode === 'enabled') return { type: 'enabled' };
if (thinkingMode === 'disabled') return { type: 'disabled' };
return undefined;
}, [thinkingMode]);
// Send message — delegates stream management to the manager
const sendMessage = useCallback(
async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => {
if (isStreaming) return;
// Use displayOverride for UI if provided (e.g. image-gen skill injection hides the skill prompt)
const displayUserContent = displayOverride || content;
// Build display content: embed file metadata as HTML comment for MessageItem to parse
let displayContent = displayUserContent;
if (files && files.length > 0) {
const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size }));
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${displayUserContent}`;
}
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content: displayContent,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, userMessage]);
// Flush pending image notices
const notices = pendingImageNoticesRef.current.length > 0
? [...pendingImageNoticesRef.current]
: undefined;
if (notices) {
pendingImageNoticesRef.current = [];
}
// Delegate to stream session manager
startStream({
sessionId,
content,
mode,
model: currentModel,
providerId: currentProviderId,
files,
systemPromptAppend,
pendingImageNotices: notices,
effort: selectedEffort,
thinking: buildThinkingConfig(),
onModeChanged: (sdkMode) => {
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
},
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
sendMessageRef.current?.(retryContent, retryFiles);
},
});
},
[sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange]
);
// Keep sendMessageRef in sync so timeout auto-retry can call it
sendMessageRef.current = sendMessage;
const handleCommand = useCallback((command: string) => {
switch (command) {
case '/help': {
const helpMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Available Commands\n\n### Instant Commands\n- **/help** — Show this help message\n- **/clear** — Clear conversation history\n- **/cost** — Show token usage statistics\n\n### Prompt Commands (shown as badge, add context then send)\n- **/compact** — Compress conversation context\n- **/doctor** — Diagnose project health\n- **/init** — Initialize CLAUDE.md for project\n- **/review** — Review code quality\n- **/terminal-setup** — Configure terminal settings\n- **/memory** — Edit project memory file\n\n### Custom Skills\nSkills from \`~/.claude/commands/\` and project \`.claude/commands/\` are also available via \`/\`.\n\n**Tips:**\n- Type \`/\` to browse commands and skills\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, helpMessage]);
break;
}
case '/clear':
setMessages([]);
// Also clear database messages and reset SDK session
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear_messages: true }),
}).catch(() => { /* silent */ });
}
break;
case '/cost': {
// Aggregate token usage from all messages in this session
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheCreation = 0;
let totalCost = 0;
let turnCount = 0;
for (const msg of messages) {
if (msg.token_usage) {
try {
const usage = typeof msg.token_usage === 'string' ? JSON.parse(msg.token_usage) : msg.token_usage;
totalInput += usage.input_tokens || 0;
totalOutput += usage.output_tokens || 0;
totalCacheRead += usage.cache_read_input_tokens || 0;
totalCacheCreation += usage.cache_creation_input_tokens || 0;
if (usage.cost_usd) totalCost += usage.cost_usd;
turnCount++;
} catch { /* skip */ }
}
}
const totalTokens = totalInput + totalOutput;
let content: string;
if (turnCount === 0) {
content = `## Token Usage\n\nNo token usage data yet. Send a message first.`;
} else {
content = `## Token Usage\n\n| Metric | Count |\n|--------|-------|\n| Input tokens | ${totalInput.toLocaleString()} |\n| Output tokens | ${totalOutput.toLocaleString()} |\n| Cache read | ${totalCacheRead.toLocaleString()} |\n| Cache creation | ${totalCacheCreation.toLocaleString()} |\n| **Total tokens** | **${totalTokens.toLocaleString()}** |\n| Turns | ${turnCount} |${totalCost > 0 ? `\n| **Estimated cost** | **$${totalCost.toFixed(4)}** |` : ''}`;
}
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
// This shouldn't be reached since non-immediate commands are handled via badge
sendMessage(command);
}
}, [sessionId, sendMessage]);
// Listen for image generation completion — persist notice to DB and queue for next user message.
// The notice is NOT sent as a separate LLM turn (avoids permission popups).
// Instead it's flushed into the next user message via pendingImageNoticesRef.
// MessageItem hides messages matching this prefix so the user doesn't see them.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail) return;
const paths = (detail.images || [])
.map((img: { localPath?: string }) => img.localPath)
.filter(Boolean);
const pathInfo = paths.length > 0 ? `\nGenerated image file paths:\n${paths.map((p: string) => `- ${p}`).join('\n')}` : '';
const notice = `[Image generation completed]\n- Prompt: "${detail.prompt}"\n- Aspect ratio: ${detail.aspectRatio}\n- Resolution: ${detail.resolution}${pathInfo}`;
// Store generated image paths so subsequent edits can use them as reference
if (paths.length > 0) {
setLastGeneratedImages(paths);
}
// Queue for next user message so the LLM gets the context
pendingImageNoticesRef.current.push(notice);
// Also persist to DB for history reload
const dbNotice = `[__IMAGE_GEN_NOTICE__ prompt: "${detail.prompt}", aspect ratio: ${detail.aspectRatio}, resolution: ${detail.resolution}${paths.length > 0 ? `, file path: ${paths.join(', ')}` : ''}]`;
fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, role: 'user', content: dbNotice }),
}).catch(() => {});
};
window.addEventListener('image-gen-completed', handler);
return () => window.removeEventListener('image-gen-completed', handler);
}, [sessionId]);
return (
<div className="flex h-full min-h-0 flex-col">
{/* Workspace mismatch banner */}
{workspaceMismatchPath && (
<div className="flex items-center justify-between gap-3 border-b border-yellow-500/30 bg-yellow-500/10 px-4 py-2">
<span className="text-xs text-yellow-700 dark:text-yellow-400">
{t('assistant.switchedBanner', { path: workspaceMismatchPath })}
</span>
<button
onClick={handleOpenNewAssistant}
className="shrink-0 rounded-md bg-yellow-600 px-3 py-1 text-xs font-medium text-white hover:bg-yellow-700 transition-colors"
>
{t('assistant.openNewAssistant')}
</button>
</div>
)}
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
onForceStop={stopStreaming}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadEarlierMessages}
rewindPoints={rewindPoints}
sessionId={sessionId}
/>
{/* Permission prompt — rendered outside MessageList so it's always visible at bottom */}
<PermissionPrompt
pendingPermission={pendingPermission}
permissionResolved={permissionResolved}
onPermissionResponse={handlePermissionResponse}
toolUses={toolUses}
permissionProfile={permissionProfile}
/>
{/* Batch image generation panels — shown above the input area */}
<BatchExecutionDashboard />
<BatchContextSync />
<MessageInput
key={sessionId}
onSend={sendMessage}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
isStreaming={isStreaming}
sessionId={sessionId}
modelName={currentModel}
onModelChange={setCurrentModel}
providerId={currentProviderId}
onProviderModelChange={handleProviderModelChange}
workingDirectory={workingDirectory}
mode={mode}
onModeChange={handleModeChange}
onAssistantTrigger={checkAssistantTrigger}
effort={selectedEffort}
onEffortChange={setSelectedEffort}
/>
<ChatComposerActionBar
left={<ImageGenToggle />}
center={
<ChatPermissionSelector
sessionId={sessionId}
permissionProfile={permissionProfile}
onPermissionChange={setPermissionProfile}
/>
}
right={
<ContextUsageIndicator
messages={messages}
modelName={currentModel}
/>
}
/>
</div>
);
}