diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 5f6b1dde..740945c4 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -80,17 +80,31 @@ function computeInitialGeom() { Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN), ); const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24); - const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN); + const maxTop = Math.max( + EDGE_MARGIN, + window.innerHeight - height - EDGE_MARGIN, + ); const top = Math.min(60, maxTop); return { left, top, width, height }; } // Clamp a geometry so the window stays within the current viewport. -function clampGeom(g: { left: number; top: number; width: number; height: number }) { +function clampGeom(g: { + left: number; + top: number; + width: number; + height: number; +}) { const effWidth = Math.max(g.width, MIN_WIDTH); const effHeight = Math.max(g.height, MIN_HEIGHT); - const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN); - const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN); + const maxLeft = Math.max( + EDGE_MARGIN, + window.innerWidth - effWidth - EDGE_MARGIN, + ); + const maxTop = Math.max( + EDGE_MARGIN, + window.innerHeight - effHeight - EDGE_MARGIN, + ); return { ...g, left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft), @@ -151,9 +165,14 @@ export default function AiChatWindow() { // Live snapshot of the active thread's useChat state, kept up to date by // ChatThread. Lets the export include the in-progress (not-yet-persisted) // streaming turn. A ref avoids re-rendering this window on every token. - const liveThreadRef = useRef<{ messages: UIMessage[]; isStreaming: boolean }>({ + const liveThreadRef = useRef<{ + messages: UIMessage[]; + isStreaming: boolean; + banner: string | null; + }>({ messages: [], isStreaming: false, + banner: null, }); // Live turn-token total (reasoning + output) for the in-flight turn, pushed up @@ -161,6 +180,12 @@ export default function AiChatWindow() { // `null` means no turn is in flight -> the badge falls back to the persisted // context size below. const [liveTurnTokens, setLiveTurnTokens] = useState(null); + // Whether the on-screen thread currently holds at least one message. Reported + // reactively by ChatThread (the live snapshot lives in a non-reactive ref). This + // lets the "Copy chat" button stay available for a brand-new, not-yet-persisted + // chat whose first turn is in flight or was interrupted — that case has no + // persisted rows yet, so a persisted-rows-only gate would hide the button (#174). + const [hasLiveContent, setHasLiveContent] = useState(false); // The page the user is currently viewing. AiChatWindow lives in a pathless // parent layout route, so useParams() can't see :pageSlug. Match the full @@ -185,17 +210,21 @@ export default function AiChatWindow() { // The invalidate closures are passed inline: `onTurnFinished` is read live by // useChat's onFinish (never in an effect dep array), so their identity does not // matter — no memoization ceremony needed. - const { threadKey, waitingForHistory, onTurnFinished, cancelPendingAdoption } = - useChatSession({ - activeChatId, - setActiveChatId, - chats, - messagesLoading, - onInvalidateChatList: () => - queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }), - onInvalidateChatMessages: (id) => - queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }), - }); + const { + threadKey, + waitingForHistory, + onTurnFinished, + cancelPendingAdoption, + } = useChatSession({ + activeChatId, + setActiveChatId, + chats, + messagesLoading, + onInvalidateChatList: () => + queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }), + onInvalidateChatMessages: (id) => + queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }), + }); // startNewChat/selectChat set the public atom; the hook's render-phase // reconciler handles the remount when activeChatId actually CHANGES. But @@ -231,13 +260,23 @@ export default function AiChatWindow() { () => chats?.items?.find((c) => c.id === activeChatId) ?? null, [chats, activeChatId], ); - const canExport = !!activeChatId && !!messageRows && messageRows.length > 0; + // Export is available when there is anything to export: either persisted rows + // for the active chat, OR a live on-screen thread with at least one message. + // The live arm covers a brand-new chat whose first turn is streaming or was + // interrupted before the server persisted any row (#174); the persisted arm is + // the steady-state path for an already-saved chat (#160). + const canExport = + hasLiveContent || + (!!activeChatId && !!messageRows && messageRows.length > 0); // The role to display in the header and as the assistant's name. Prefer the // persisted role of an existing chat (chat-list JOIN); fall back to the role // picked via a card click for a brand-new or just-adopted chat. selectChat // resets selectedRoleId, so this fallback never leaks into an unrelated chat. - const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => { + const currentRole = useMemo<{ + name: string; + emoji: string | null; + } | null>(() => { if (activeChat?.roleName) { return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null }; } @@ -249,28 +288,44 @@ export default function AiChatWindow() { // call) and copy it to the clipboard. The "Copied" notification is the // feedback. const handleCopy = useCallback(() => { - if (!activeChatId || !messageRows || messageRows.length === 0) return; - // While the active thread is streaming, the current user message and the - // in-progress assistant reply are NOT yet in messageRows (the persisted - // query is only refetched after the turn finishes). Pull the live tail — - // messages whose id is not among the persisted rows — and append them, - // flagging the streaming assistant message as still generating. + // Export gate. There must be SOMETHING to export — either a live on-screen + // message or a persisted row. A brand-new chat whose first turn is streaming + // or was interrupted has live messages but no persisted rows yet; it still + // exports the on-screen thread WYSIWYG (#174). Only a truly empty chat (no + // live messages and no rows) is non-exportable (the button is hidden too — + // see `canExport`). const live = liveThreadRef.current; - const rowIds = new Set(messageRows.map((r) => r.id)); - const pending = live.isStreaming - ? live.messages - .filter((m) => !rowIds.has(m.id)) - .map((m) => ({ - role: m.role, - parts: (m.parts ?? []) as { type: string; text?: string }[], - generating: m.role === "assistant", - })) - : []; + const hasRows = !!messageRows && messageRows.length > 0; + if (live.messages.length === 0 && !hasRows) return; + // WYSIWYG export: the live on-screen messages ARE the document (so a partial + // reply from an interrupted turn — which never reached the persisted rows — + // is exported just as it appears). The persisted rows enrich each live + // message (token usage / error / timestamp) by id and serve as the fallback + // when the live mirror is empty. The on-screen banner is appended too. See + // issues #160 and #174. `chatId` may be null for a not-yet-saved chat — use a + // placeholder so the header line still renders. const markdown = buildChatMarkdown({ title: activeChat?.title ?? null, - chatId: activeChatId, + chatId: activeChatId ?? "unsaved", + live: live.messages.map((m) => ({ + id: m.id, + role: m.role, + parts: (m.parts ?? []) as { type: string; text?: string }[], + metadata: m.metadata as + | { + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningTokens?: number; + }; + error?: string; + } + | undefined, + })), rows: messageRows, - pending, + isStreaming: live.isStreaming, + banner: live.banner, t, }); clipboard.copy(markdown); @@ -351,7 +406,8 @@ export default function AiChatWindow() { const width = el.offsetWidth; const height = el.offsetHeight; setGeom((prev) => { - if (!prev || (prev.width === width && prev.height === height)) return prev; + if (!prev || (prev.width === width && prev.height === height)) + return prev; return { ...prev, width, height }; }); }); @@ -497,11 +553,15 @@ export default function AiChatWindow() { flash a "0" badge before any token streams in (#151 review). */} {liveTurnTokens !== null && liveTurnTokens > 0 ? ( - {formatTokens(liveTurnTokens)} + + {formatTokens(liveTurnTokens)} + ) : contextTokens > 0 ? ( - {formatTokens(contextTokens)} + + {formatTokens(contextTokens)} + ) : null} @@ -515,7 +575,11 @@ export default function AiChatWindow() { aria-label={t("Copy chat")} onClick={handleCopy} > - {clipboard.copied ? : } + {clipboard.copied ? ( + + ) : ( + + )} )}