shelley/ui: Add collapsible system prompt viewer at top of conversation

Philip Zeyliger and Shelley created

Prompt: (In a new worktree) At the very top of the conversation/timeline view, add a way to "show" the system prompt, which I think the UI has. Let it be expanded to see it.

Add a SystemPromptView component that:
- Shows at the very top of the conversation/timeline view
- Displays system prompt info (line count, size in KB)
- Is collapsed by default
- Expands to show full system prompt text when clicked

The viewer uses the same styling pattern as other expandable tool components.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

ui/src/components/ChatInterface.tsx    | 10 +++
ui/src/components/SystemPromptView.tsx | 83 ++++++++++++++++++++++++++++
ui/src/styles.css                      | 81 +++++++++++++++++++++++++++
3 files changed, 173 insertions(+), 1 deletion(-)

Detailed changes

ui/src/components/ChatInterface.tsx 🔗

@@ -29,6 +29,7 @@ import DirectoryPickerModal from "./DirectoryPickerModal";
 import { useVersionChecker } from "./VersionChecker";
 import TerminalWidget from "./TerminalWidget";
 import ModelPicker from "./ModelPicker";
+import SystemPromptView from "./SystemPromptView";
 
 // Ephemeral terminal instance (not persisted to database)
 interface EphemeralTerminal {
@@ -1251,8 +1252,15 @@ function ChatInterface({
       return null;
     });
 
+    // Find system message to render at the top
+    const systemMessage = messages.find((m) => m.type === "system");
+
     // Append ephemeral terminals at the end
-    return [...rendered, ...terminalElements];
+    return [
+      systemMessage && <SystemPromptView key="system-prompt" message={systemMessage} />,
+      ...rendered,
+      ...terminalElements,
+    ];
   };
 
   return (

ui/src/components/SystemPromptView.tsx 🔗

@@ -0,0 +1,83 @@
+import React, { useState } from "react";
+import { Message, LLMContent } from "../types";
+
+interface SystemPromptViewProps {
+  message: Message;
+}
+
+function SystemPromptView({ message }: SystemPromptViewProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract system prompt text from llm_data
+  let systemPromptText = "";
+  if (message.llm_data) {
+    try {
+      const llmData =
+        typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+      if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
+        const textContent = llmData.Content.find((c: LLMContent) => c.Type === 2 && c.Text);
+        if (textContent) {
+          systemPromptText = textContent.Text;
+        }
+      }
+    } catch (err) {
+      console.error("Failed to parse system prompt:", err);
+    }
+  }
+
+  if (!systemPromptText) {
+    return null;
+  }
+
+  // Count lines and approximate size
+  const lineCount = systemPromptText.split("\n").length;
+  const charCount = systemPromptText.length;
+  const sizeKb = (charCount / 1024).toFixed(1);
+
+  return (
+    <div className="system-prompt-view">
+      <div className="system-prompt-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="system-prompt-summary">
+          <span className="system-prompt-icon">📋</span>
+          <span className="system-prompt-label">System Prompt</span>
+          <span className="system-prompt-meta">
+            {lineCount} lines, {sizeKb} KB
+          </span>
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="system-prompt-content">
+          <pre className="system-prompt-text">{systemPromptText}</pre>
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default SystemPromptView;

ui/src/styles.css 🔗

@@ -4801,3 +4801,84 @@ svg {
   margin-top: 1rem;
   padding-bottom: 0.5rem;
 }
+
+/* System Prompt View */
+.system-prompt-view {
+  background: var(--gray-100);
+  border-radius: 0.5rem;
+  margin: 0.5rem 0;
+  width: 100%;
+  border: 1px dashed var(--border);
+}
+
+.dark .system-prompt-view {
+  background: var(--gray-800);
+}
+
+.system-prompt-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1rem;
+  cursor: pointer;
+  user-select: none;
+}
+
+.system-prompt-header:hover {
+  background: rgba(0, 0, 0, 0.02);
+  border-radius: 0.5rem;
+}
+
+.dark .system-prompt-header:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.system-prompt-summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex: 1;
+  min-width: 0;
+}
+
+.system-prompt-icon {
+  font-size: 1rem;
+  flex-shrink: 0;
+}
+
+.system-prompt-label {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  color: var(--text-primary);
+  font-weight: 500;
+}
+
+.system-prompt-meta {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.system-prompt-content {
+  padding: 0 1rem 1rem 1rem;
+}
+
+.system-prompt-text {
+  font-family: var(--font-mono);
+  font-size: 0.75rem;
+  line-height: 1.5;
+  color: var(--text-secondary);
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: 0.375rem;
+  padding: 1rem;
+  margin: 0;
+  max-height: 400px;
+  overflow: auto;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+}
+
+.dark .system-prompt-text {
+  background: var(--gray-900);
+}