- ChatInput: e.nativeEvent.isComposing early-return added (CJK IME safety — first Enter of a composition no longer submits). Bare-Enter send path gated by !isMobile so mobile inserts a newline; send is button-only. Cmd/Ctrl+Enter and Shift+Cmd/Ctrl+Enter retained as desktop secondary bindings. Placeholder is now viewport-aware. Outer wrapper gets paddingBottom: env(safe-area-inset-bottom) so iOS home indicator doesn't overlap. - MessageBubble: ActionRow buttons (Copy / Regenerate / Fork / Trash) bumped to max-md min-h/min-w 44px; opacity-100 on mobile so actions don't hide behind a hover-to-reveal pattern. User bubble and assistant content wrapper gain break-words + min-w-0 so long unbreakable strings (URLs / paths) wrap rather than blowing out the column on narrow viewports. - ChatPane: queued-message dropdown + close X + Stop-generating button hit max-md 44px sizing. - ChatTabBar: per-tab X, +/History/Close-pane action buttons hit max-md 44px. Tab close X is force-visible on mobile (no hover-to-reveal). - M8: CodeBlock / Markdown tables / ToolCallCard already wrap overflow-x-auto pre-existing — no source change needed there; the break-words + min-w-0 additions above are the new defensive layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
515 lines
18 KiB
TypeScript
515 lines
18 KiB
TypeScript
import { Children, cloneElement, isValidElement, useState } from 'react';
|
|
import type { ReactElement, ReactNode } from 'react';
|
|
import Markdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import type { Chat, Message } from '@/api/types';
|
|
import { api } from '@/api/client';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
import { ToolCallCard } from './ToolCallCard';
|
|
import { CodeBlock } from './CodeBlock';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
|
|
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
|
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
|
// match, but `src/foo.ts` will). False positives at the edges are accepted
|
|
// per Sam's design decision (2026-05-14).
|
|
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
|
|
|
function isPathLike(s: string): boolean {
|
|
return s.includes('/');
|
|
}
|
|
|
|
function emitOpenFile(path: string): void {
|
|
sessionEvents.emit({ type: 'open_file_in_browser', path });
|
|
}
|
|
|
|
// Split a plain string into a flat array of strings and clickable button
|
|
// nodes for path-shaped substrings. If no matches, returns the original
|
|
// string verbatim (no array wrapping).
|
|
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
|
|
const out: ReactNode[] = [];
|
|
let lastIdx = 0;
|
|
let idx = 0;
|
|
for (const match of text.matchAll(PATH_REGEX)) {
|
|
const matchedText = match[0];
|
|
const start = match.index ?? 0;
|
|
if (!isPathLike(matchedText)) continue;
|
|
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
|
out.push(
|
|
<button
|
|
key={`${keyPrefix}-${idx}`}
|
|
type="button"
|
|
onClick={() => emitOpenFile(matchedText)}
|
|
className="text-primary underline cursor-pointer hover:text-primary/80"
|
|
>
|
|
{matchedText}
|
|
</button>
|
|
);
|
|
lastIdx = start + matchedText.length;
|
|
idx += 1;
|
|
}
|
|
if (out.length === 0) return text;
|
|
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
|
return out;
|
|
}
|
|
|
|
// Walk react-markdown children, linkifying string text nodes. Children of
|
|
// <code> nodes (CodeBlock and inline code) are left untouched — the regex
|
|
// shouldn't run inside code spans.
|
|
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
|
const arr = Children.toArray(children);
|
|
return arr.map((child, i) => {
|
|
if (typeof child === 'string') {
|
|
return (
|
|
<span key={`${keyPrefix}-${i}`}>
|
|
{linkifyPaths(child, `${keyPrefix}-${i}`)}
|
|
</span>
|
|
);
|
|
}
|
|
if (isValidElement(child)) {
|
|
const el = child as ReactElement<{ children?: ReactNode }>;
|
|
// Skip inline/block code — paths in code spans aren't link targets.
|
|
if (el.type === 'code' || el.type === CodeBlock) return child;
|
|
const grandchildren = el.props.children;
|
|
if (grandchildren === undefined) return child;
|
|
return cloneElement(el, {
|
|
key: el.key ?? `linkified-${i}`,
|
|
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
|
|
});
|
|
}
|
|
return child;
|
|
});
|
|
}
|
|
|
|
interface Props {
|
|
message: Message;
|
|
sessionChats?: Chat[];
|
|
}
|
|
|
|
function MarkdownBody({ content }: { content: string }) {
|
|
return (
|
|
<Markdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
pre: ({ children }) => <>{children}</>,
|
|
code: (props) => {
|
|
const { children, className, ...rest } = props as {
|
|
children?: unknown;
|
|
className?: string;
|
|
};
|
|
const text = String(children ?? '').replace(/\n$/, '');
|
|
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
|
const isBlock = !!langMatch || text.includes('\n');
|
|
if (isBlock) {
|
|
return <CodeBlock code={text} lang={langMatch?.[1]} />;
|
|
}
|
|
return (
|
|
<code
|
|
{...rest}
|
|
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
|
>
|
|
{children as React.ReactNode}
|
|
</code>
|
|
);
|
|
},
|
|
a: ({ children, href }) => (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
|
>
|
|
{children}
|
|
</a>
|
|
),
|
|
ul: ({ children }) => (
|
|
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
|
),
|
|
ol: ({ children }) => (
|
|
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
|
),
|
|
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
|
p: ({ children }) => (
|
|
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
|
),
|
|
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
|
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
|
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
|
blockquote: ({ children }) => (
|
|
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
table: ({ children }) => (
|
|
<div className="overflow-x-auto">
|
|
<table className="border-collapse text-xs">{children}</table>
|
|
</div>
|
|
),
|
|
th: ({ children }) => (
|
|
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
|
),
|
|
td: ({ children }) => (
|
|
<td className="border border-border px-2 py-1">
|
|
{linkifyChildren(children)}
|
|
</td>
|
|
),
|
|
}}
|
|
>
|
|
{content}
|
|
</Markdown>
|
|
);
|
|
}
|
|
|
|
function StatsLine({ message }: { message: Message }) {
|
|
const tokens = message.tokens_used;
|
|
if (typeof tokens !== 'number' || tokens <= 0) return null;
|
|
const started = message.started_at ? Date.parse(message.started_at) : NaN;
|
|
const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
|
|
let tps: number | null = null;
|
|
if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
|
|
const seconds = (finished - started) / 1000;
|
|
if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
|
|
}
|
|
const ctxUsed = message.ctx_used;
|
|
const ctxMax = message.ctx_max;
|
|
const ctxPart =
|
|
typeof ctxUsed === 'number'
|
|
? typeof ctxMax === 'number' && ctxMax > 0
|
|
? `${ctxUsed} / ${ctxMax} ctx`
|
|
: `${ctxUsed} ctx`
|
|
: null;
|
|
|
|
const parts: string[] = [`${tokens} tokens`];
|
|
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
|
|
if (ctxPart) parts.push(ctxPart);
|
|
|
|
return (
|
|
<div className="text-[10px] font-mono text-muted-foreground">
|
|
{parts.join(' · ')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionRow({
|
|
message,
|
|
}: {
|
|
message: Message;
|
|
}) {
|
|
const [justCopied, setJustCopied] = useState(false);
|
|
const [regenerating, setRegenerating] = useState(false);
|
|
const [forking, setForking] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
async function copy() {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content);
|
|
setJustCopied(true);
|
|
setTimeout(() => setJustCopied(false), 1200);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'copy failed');
|
|
}
|
|
}
|
|
|
|
async function regenerate() {
|
|
if (regenerating || message.status === 'streaming') return;
|
|
setRegenerating(true);
|
|
try {
|
|
await api.messages.regenerate(message.chat_id, message.id);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
|
} finally {
|
|
setRegenerating(false);
|
|
}
|
|
}
|
|
|
|
async function fork() {
|
|
if (forking || message.status !== 'complete') return;
|
|
setForking(true);
|
|
try {
|
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
|
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
|
} finally {
|
|
setForking(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (deleting) return;
|
|
setDeleting(true);
|
|
try {
|
|
await api.messages.remove(message.chat_id, message.id);
|
|
setDeleteOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'delete failed');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
const isAssistant = message.role === 'assistant';
|
|
const canRegen = isAssistant && message.status !== 'streaming';
|
|
const canFork = message.status === 'complete';
|
|
const canDelete = message.status !== 'streaming';
|
|
|
|
return (
|
|
<>
|
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
|
|
<button
|
|
type="button"
|
|
onClick={() => void copy()}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Copy message"
|
|
title="Copy"
|
|
>
|
|
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
</button>
|
|
{isAssistant && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void regenerate()}
|
|
disabled={!canRegen || regenerating}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Regenerate message"
|
|
title="Regenerate"
|
|
>
|
|
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => void fork()}
|
|
disabled={!canFork || forking}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Fork from here"
|
|
title="Fork from here"
|
|
>
|
|
<GitFork className="size-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteOpen(true)}
|
|
disabled={!canDelete}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Delete message"
|
|
title="Delete message"
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</button>
|
|
</div>
|
|
<Dialog
|
|
open={deleteOpen}
|
|
onOpenChange={(open) => {
|
|
if (!deleting) setDeleteOpen(open);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
|
|
<DialogDescription>
|
|
This removes the selected message and every later message in this chat. This cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteOpen(false)}
|
|
disabled={deleting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => void confirmDelete()}
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? 'Deleting…' : 'Delete'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [shareOpen, setShareOpen] = useState(false);
|
|
const [rerunning, setRerunning] = useState(false);
|
|
|
|
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
|
|
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
|
|
const summaryText = headerMatch
|
|
? message.content.slice(headerMatch[0].length).trim()
|
|
: message.content;
|
|
|
|
async function handleCopy() {
|
|
try {
|
|
await navigator.clipboard.writeText(summaryText);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1200);
|
|
toast.success('Summary copied to clipboard');
|
|
} catch {
|
|
toast.error('Copy failed');
|
|
}
|
|
}
|
|
|
|
async function handleShareToChat(chat: Chat) {
|
|
try {
|
|
await api.messages.send(chat.id, summaryText);
|
|
toast.success(`Summary sent to ${chat.name ?? 'New chat'}`);
|
|
setShareOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to share');
|
|
}
|
|
}
|
|
|
|
async function handleRerun() {
|
|
if (rerunning) return;
|
|
setRerunning(true);
|
|
try {
|
|
await api.chats.compact(message.chat_id);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Re-run failed');
|
|
} finally {
|
|
setRerunning(false);
|
|
}
|
|
}
|
|
|
|
const otherChats = (sessionChats ?? []).filter(
|
|
(c) => c.id !== message.chat_id && c.status === 'open'
|
|
);
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 text-sm">
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
|
|
>
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
<span className="text-xs font-medium truncate">{headerText}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCopy()}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
|
aria-label="Copy summary"
|
|
title="Copy summary"
|
|
>
|
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShareOpen(!shareOpen)}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
|
aria-label="Send to chat"
|
|
title="Send to chat"
|
|
>
|
|
<Share2 size={12} />
|
|
</button>
|
|
{shareOpen && (
|
|
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[180px] py-1">
|
|
{otherChats.length === 0 ? (
|
|
<div className="px-3 py-1.5 text-xs text-muted-foreground">
|
|
No other chats in this session
|
|
</div>
|
|
) : (
|
|
otherChats.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => void handleShareToChat(c)}
|
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
|
|
>
|
|
{c.name ?? 'New chat'}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRerun()}
|
|
disabled={rerunning}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40"
|
|
aria-label="Re-run compact"
|
|
title="Re-run compact"
|
|
>
|
|
<RotateCw size={12} className={rerunning ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
{expanded && (
|
|
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
|
{summaryText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MessageBubble({ message, sessionChats }: Props) {
|
|
if (message.kind === 'compact') {
|
|
return <CompactCard message={message} sessionChats={sessionChats} />;
|
|
}
|
|
|
|
if (message.role === 'tool') {
|
|
return <ToolCallCard message={message} />;
|
|
}
|
|
|
|
if (message.role === 'user') {
|
|
return (
|
|
<div className="group flex flex-col items-end gap-1">
|
|
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
|
{message.content}
|
|
</div>
|
|
<ActionRow message={message} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isStreaming = message.status === 'streaming';
|
|
const failed = message.status === 'failed';
|
|
const hasContent = message.content.length > 0;
|
|
const hasToolCalls = (message.tool_calls?.length ?? 0) > 0;
|
|
|
|
return (
|
|
<div className="group flex flex-col gap-2">
|
|
{message.tool_calls?.map((tc) => (
|
|
<ToolCallCard key={tc.id} toolCall={tc} />
|
|
))}
|
|
{(hasContent || (!hasToolCalls && isStreaming)) && (
|
|
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
|
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
|
{isStreaming && (
|
|
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
|
)}
|
|
</div>
|
|
)}
|
|
{failed && (
|
|
<div className="text-xs text-destructive">message failed</div>
|
|
)}
|
|
{!isStreaming && <StatsLine message={message} />}
|
|
{!isStreaming && (hasContent || hasToolCalls) && (
|
|
<ActionRow message={message} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|