| 1 | import { useState, useCallback, type PointerEvent as ReactPointerEvent } from "react"; |
| 2 | import type { TraceRecord } from "@/hooks/usePromptStack"; |
| 3 | |
| 4 | interface Props { |
| 5 | record: TraceRecord; |
| 6 | } |
| 7 | |
| 8 | const MSG_SPLIT_KEY = "promptstack.msg-split"; |
| 9 | const MIN_TOP = 80; |
| 10 | const MIN_BOTTOM = 80; |
| 11 | |
| 12 | function readMsgSplit(): number { |
| 13 | try { |
| 14 | const raw = window.localStorage.getItem(MSG_SPLIT_KEY); |
| 15 | if (raw) { |
| 16 | const val = Number(raw); |
| 17 | if (Number.isFinite(val) && val > 0.1 && val < 0.9) return val; |
| 18 | } |
| 19 | } catch { /* ignore */ } |
| 20 | return 0.4; |
| 21 | } |
| 22 | |
| 23 | export function ResponseView({ record }: Props) { |
| 24 | const [expandedMsgs, setExpandedMsgs] = useState<Set<number>>(new Set()); |
| 25 | const [msgSplit, setMsgSplit] = useState(readMsgSplit); |
| 26 | const { response } = record; |
| 27 | |
| 28 | const toggleMsg = (idx: number) => { |
| 29 | setExpandedMsgs((prev) => { |
| 30 | const next = new Set(prev); |
| 31 | if (next.has(idx)) next.delete(idx); |
| 32 | else next.add(idx); |
| 33 | return next; |
| 34 | }); |
| 35 | }; |
| 36 | |
| 37 | const startMsgResize = useCallback( |
| 38 | (event: ReactPointerEvent<HTMLDivElement>) => { |
| 39 | event.preventDefault(); |
| 40 | const container = event.currentTarget.parentElement as HTMLElement; |
| 41 | const rect = container.getBoundingClientRect(); |
| 42 | const h = rect.height; |
| 43 | |
| 44 | const onMove = (e: PointerEvent) => { |
| 45 | const ratio = Math.max( |
| 46 | MIN_TOP / h, |
| 47 | Math.min(1 - MIN_BOTTOM / h, (e.clientY - rect.top) / h), |
| 48 | ); |
| 49 | setMsgSplit(ratio); |
| 50 | }; |
| 51 | const onUp = () => { |
| 52 | window.removeEventListener("pointermove", onMove); |
| 53 | window.removeEventListener("pointerup", onUp); |
| 54 | try { window.localStorage.setItem(MSG_SPLIT_KEY, String(msgSplit)); } catch { /* */ } |
| 55 | }; |
| 56 | window.addEventListener("pointermove", onMove); |
| 57 | window.addEventListener("pointerup", onUp); |
| 58 | }, |
| 59 | [msgSplit], |
| 60 | ); |
| 61 | |
| 62 | const roleColor = (role: string) => { |
| 63 | switch (role) { |
| 64 | case "assistant": return "text-green-600 dark:text-green-400"; |
| 65 | case "user": return "text-blue-600 dark:text-blue-400"; |
| 66 | case "system": return "text-purple-600 dark:text-purple-400"; |
| 67 | default: return "text-orange-600 dark:text-orange-400"; |
| 68 | } |
| 69 | }; |
| 70 | |
| 71 | return ( |
| 72 | <div className="flex flex-col h-full overflow-hidden"> |
| 73 | {/* Upper section: response summary — scrolls independently */} |
| 74 | <div className="overflow-y-auto shrink-0" style={{ height: `${msgSplit * 100}%` }}> |
| 75 | <div className="flex flex-col gap-3 p-1"> |
| 76 | {/* Usage */} |
| 77 | {response.usage && ( |
| 78 | <div className="flex flex-wrap gap-3 text-xs text-muted-foreground"> |
| 79 | {Object.entries(response.usage).map(([key, val]) => ( |
| 80 | <span key={key} className="font-mono"> |
| 81 | {key}: {val} |
| 82 | </span> |
| 83 | ))} |
| 84 | </div> |
| 85 | )} |
| 86 | |
| 87 | {/* Tool calls */} |
| 88 | {response.tool_calls && response.tool_calls.length > 0 && ( |
| 89 | <div className="flex flex-col gap-1"> |
| 90 | <span className="text-xs font-medium text-muted-foreground">Tool Calls:</span> |
| 91 | <div className="flex flex-wrap gap-1"> |
| 92 | {response.tool_calls.map((tc, i) => ( |
| 93 | <span |
| 94 | key={i} |
| 95 | className="inline-flex items-center rounded-md bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-700 dark:text-orange-300" |
| 96 | > |
| 97 | {tc.name} |
| 98 | </span> |
| 99 | ))} |
| 100 | </div> |
| 101 | </div> |
| 102 | )} |
| 103 | |
| 104 | {/* Reasoning */} |
| 105 | {response.reasoning_content && ( |
| 106 | <div className="flex flex-col gap-1"> |
| 107 | <span className="text-xs font-medium text-muted-foreground">Reasoning:</span> |
| 108 | <pre className="text-xs whitespace-pre-wrap break-words font-mono bg-muted/30 rounded-md p-2 max-h-40 overflow-auto"> |
| 109 | {response.reasoning_content} |
| 110 | </pre> |
| 111 | </div> |
| 112 | )} |
| 113 | |
| 114 | {/* Response content */} |
| 115 | {response.content && ( |
| 116 | <div className="flex flex-col gap-1"> |
| 117 | <span className="text-xs font-medium text-muted-foreground">Response:</span> |
| 118 | <pre className="text-xs whitespace-pre-wrap break-words font-mono bg-muted/30 rounded-md p-3 max-h-60 overflow-auto"> |
| 119 | {response.content} |
| 120 | </pre> |
| 121 | </div> |
| 122 | )} |
| 123 | </div> |
| 124 | </div> |
| 125 | |
| 126 | {/* Drag handle */} |
| 127 | <div |
| 128 | className="h-2 shrink-0 cursor-row-resize flex items-center justify-center hover:bg-muted/50 transition-colors group" |
| 129 | onPointerDown={startMsgResize} |
| 130 | > |
| 131 | <div className="w-12 h-0.5 rounded-full bg-border group-hover:bg-foreground/30 transition-colors" /> |
| 132 | </div> |
| 133 | |
| 134 | {/* Lower section: messages list — independently scrollable */} |
| 135 | <div className="flex-1 overflow-hidden flex flex-col border-t min-h-0"> |
| 136 | <div className="px-2 py-1 text-xs font-medium text-muted-foreground bg-muted/30 border-b shrink-0"> |
| 137 | Messages ({record.messages_count} total) |
| 138 | </div> |
| 139 | <div className="flex-1 overflow-y-auto"> |
| 140 | <div className="flex flex-col gap-0.5 p-1"> |
| 141 | {record.messages.map((msg, i) => ( |
| 142 | <div key={i} className="border rounded-md overflow-hidden"> |
| 143 | <button |
| 144 | onClick={() => toggleMsg(i)} |
| 145 | className="flex w-full items-center gap-2 px-2 py-1 text-xs hover:bg-muted/50 transition-colors text-left" |
| 146 | > |
| 147 | <span className={`font-medium shrink-0 w-14 ${roleColor(msg.role)}`}> |
| 148 | {msg.role} |
| 149 | </span> |
| 150 | {msg.tool_name && ( |
| 151 | <span className="text-orange-500 font-mono shrink-0">[{msg.tool_name}]</span> |
| 152 | )} |
| 153 | {msg.tool_calls && msg.tool_calls.length > 0 && ( |
| 154 | <span className="text-orange-500 font-mono shrink-0"> |
| 155 | [{msg.tool_calls.map(tc => tc.name).join(", ")}] |
| 156 | </span> |
| 157 | )} |
| 158 | <span className="text-muted-foreground truncate flex-1">{msg.preview}</span> |
| 159 | <span className="text-muted-foreground font-mono shrink-0 text-[10px]"> |
| 160 | {msg.char_count >= 1000 |
| 161 | ? `${(msg.char_count / 1000).toFixed(1)}k` |
| 162 | : msg.char_count} |
| 163 | </span> |
| 164 | <span className="text-muted-foreground text-[10px]"> |
| 165 | {expandedMsgs.has(i) ? "▼" : "▶"} |
| 166 | </span> |
| 167 | </button> |
| 168 | {expandedMsgs.has(i) && ( |
| 169 | <div className="border-t bg-muted/20 p-2 max-h-80 overflow-auto"> |
| 170 | <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80"> |
| 171 | {msg.content} |
| 172 | </pre> |
| 173 | {msg.tool_calls && msg.tool_calls.length > 0 && ( |
| 174 | <div className="mt-2 border-t pt-2"> |
| 175 | <span className="text-[10px] text-muted-foreground font-medium">Tool Call Arguments:</span> |
| 176 | {msg.tool_calls.map((tc, j) => ( |
| 177 | <pre key={j} className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/60 mt-1"> |
| 178 | {tc.name}: {tc.arguments} |
| 179 | </pre> |
| 180 | ))} |
| 181 | </div> |
| 182 | )} |
| 183 | </div> |
| 184 | )} |
| 185 | </div> |
| 186 | ))} |
| 187 | </div> |
| 188 | </div> |
| 189 | </div> |
| 190 | </div> |
| 191 | ); |
| 192 | } |
| 193 |