From 3c5c4755b8ae29dbce0bafae9958f214f902f23f Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 23 Oct 2025 11:14:35 +0100 Subject: [PATCH] Add logs tab with real-time server output and consolidate syntax highlighting - Implement dedicated Logs tab showing stdout/stderr from OpenCode server - Add log level parsing (INFO, ERROR, WARN, DEBUG) with color coding - Stream logs from main process to renderer via IPC events - Persist scroll position and auto-scroll state per instance - Synchronize instance IDs between renderer and main process - Consolidate syntax highlighting to single shared highlighter instance - Optimize markdown rendering with global highlighter initialization - Fix code block copy button to always appear on right side - Enable debug logging with --print-logs --log-level DEBUG flags --- electron/main/ipc.ts | 8 +- electron/main/process-manager.ts | 49 ++- electron/preload/index.ts | 9 +- src/App.tsx | 14 +- src/components/code-block-inline.tsx | 68 +--- src/components/logs-view.tsx | 118 +++++++ src/components/markdown.tsx | 32 +- src/index.css | 1 + src/lib/markdown.ts | 71 ++-- src/stores/instances.ts | 58 +++- src/types/instance.ts | 13 +- tasks/README.md | 16 +- tasks/done/013-logs-tab.md | 479 +++++++++++++++++++++++++++ 13 files changed, 779 insertions(+), 157 deletions(-) create mode 100644 src/components/logs-view.tsx create mode 100644 tasks/done/013-logs-tab.md diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index 4c52535b..a3fc0c43 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -18,9 +18,9 @@ function generateId(): string { } export function setupInstanceIPC(mainWindow: BrowserWindow) { - ipcMain.handle("instance:create", async (event, folder: string) => { - const id = generateId() + processManager.setMainWindow(mainWindow) + ipcMain.handle("instance:create", async (event, id: string, folder: string) => { const instance: Instance = { id, folder, @@ -32,7 +32,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { instances.set(id, instance) try { - const { pid, port } = await processManager.spawn(folder) + const { pid, port } = await processManager.spawn(folder, id) instance.port = port instance.pid = pid @@ -48,7 +48,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { }) } - return { port, pid } + return { id, port, pid } } catch (error) { instance.status = "error" instance.error = error instanceof Error ? error.message : String(error) diff --git a/electron/main/process-manager.ts b/electron/main/process-manager.ts index 8143cc50..0a86d355 100644 --- a/electron/main/process-manager.ts +++ b/electron/main/process-manager.ts @@ -1,5 +1,5 @@ import { spawn, ChildProcess } from "child_process" -import { app } from "electron" +import { app, BrowserWindow } from "electron" import { existsSync, statSync } from "fs" import { execSync } from "child_process" @@ -15,17 +15,48 @@ interface ProcessMeta { startTime: number childProcess: ChildProcess logs: string[] + instanceId: string } class ProcessManager { private processes = new Map() + private mainWindow: BrowserWindow | null = null - async spawn(folder: string): Promise { + setMainWindow(window: BrowserWindow) { + this.mainWindow = window + } + + private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" { + const upperMessage = message.toUpperCase() + if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error" + if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn" + if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug" + if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info" + return "info" + } + + private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) { + if (this.mainWindow && message.trim()) { + const parsedLevel = this.parseLogLevel(message) + this.mainWindow.webContents.send("instance:log", { + id: instanceId, + entry: { + timestamp: Date.now(), + level: parsedLevel, + message: message.trim(), + }, + }) + } + } + + async spawn(folder: string, instanceId: string): Promise { this.validateFolder(folder) this.validateOpenCodeBinary() + this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder}...`) + return new Promise((resolve, reject) => { - const child = spawn("opencode", ["serve", "--port", "0"], { + const child = spawn("opencode", ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], { cwd: folder, stdio: ["ignore", "pipe", "pipe"], env: process.env, @@ -34,6 +65,7 @@ class ProcessManager { const timeout = setTimeout(() => { child.kill("SIGKILL") + this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)") reject(new Error("Server startup timeout (10s exceeded)")) }, 10000) @@ -49,6 +81,10 @@ class ProcessManager { stdoutBuffer = lines.pop() || "" for (const line of lines) { + if (!line.trim()) continue + + this.sendLog(instanceId, "info", line) + const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/) if (portMatch && !portFound) { portFound = true @@ -62,13 +98,13 @@ class ProcessManager { startTime: Date.now(), childProcess: child, logs: [line], + instanceId, } this.processes.set(child.pid!, meta) resolve({ pid: child.pid!, port }) } - const logEntry = { timestamp: Date.now(), level: "info", message: line } const meta = this.processes.get(child.pid!) if (meta) { meta.logs.push(line) @@ -84,7 +120,10 @@ class ProcessManager { stderrBuffer = lines.pop() || "" for (const line of lines) { - const logEntry = { timestamp: Date.now(), level: "error", message: line } + if (!line.trim()) continue + + this.sendLog(instanceId, "error", line) + const meta = this.processes.get(child.pid!) if (meta) { meta.logs.push(line) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 732dc1a6..ab9dc856 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -2,20 +2,23 @@ import { contextBridge, ipcRenderer } from "electron" export interface ElectronAPI { selectFolder: () => Promise - createInstance: (folder: string) => Promise<{ port: number; pid: number }> + createInstance: (id: string, folder: string) => Promise<{ id: string; port: number; pid: number }> stopInstance: (pid: number) => Promise onInstanceStarted: (callback: (data: { id: string; port: number; pid: number }) => void) => void onInstanceError: (callback: (data: { id: string; error: string }) => void) => void onInstanceStopped: (callback: (data: { id: string }) => void) => void onInstanceLog: ( - callback: (data: { id: string; entry: { timestamp: number; level: string; message: string } }) => void, + callback: (data: { + id: string + entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string } + }) => void, ) => void onNewInstance: (callback: () => void) => void } const electronAPI: ElectronAPI = { selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"), - createInstance: (folder: string) => ipcRenderer.invoke("instance:create", folder), + createInstance: (id: string, folder: string) => ipcRenderer.invoke("instance:create", id, folder), stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid), onInstanceStarted: (callback) => { ipcRenderer.on("instance:started", (_, data) => callback(data)) diff --git a/src/App.tsx b/src/App.tsx index 804e7c86..efa9d6f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import InstanceTabs from "./components/instance-tabs" import SessionTabs from "./components/session-tabs" import MessageStream from "./components/message-stream" import PromptInput from "./components/prompt-input" +import LogsView from "./components/logs-view" +import { initMarkdown } from "./lib/markdown" import { hasInstances, isSelectingFolder, @@ -23,6 +25,7 @@ import { setActiveInstanceId, stopInstance, getActiveInstance, + addLog, } from "./stores/instances" import { getSessions, @@ -173,6 +176,8 @@ const App: Component = () => { } onMount(() => { + initMarkdown(false).catch(console.error) + setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession) window.electronAPI.onNewInstance(() => { @@ -193,6 +198,10 @@ const App: Component = () => { console.log("Instance stopped:", id) updateInstance(id, { status: "stopped" }) }) + + window.electronAPI.onInstanceLog(({ id, entry }) => { + addLog(id, entry) + }) }) return ( @@ -255,10 +264,7 @@ const App: Component = () => { } > -
-

Server Logs

-

Log viewer will be implemented in Task 013

-
+ diff --git a/src/components/code-block-inline.tsx b/src/components/code-block-inline.tsx index 51d144db..ada17766 100644 --- a/src/components/code-block-inline.tsx +++ b/src/components/code-block-inline.tsx @@ -1,67 +1,42 @@ -import { createSignal, onMount, Show } from "solid-js" -import { getHighlighter, type Highlighter } from "shiki" +import { createSignal, onMount, Show, createEffect } from "solid-js" +import type { Highlighter } from "shiki" import { useTheme } from "../lib/theme" +import { getSharedHighlighter, escapeHtml } from "../lib/markdown" interface CodeBlockInlineProps { code: string language?: string } -let highlighter: Highlighter | null = null - -async function getOrCreateHighlighter() { - if (!highlighter) { - highlighter = await getHighlighter({ - themes: ["github-light", "github-dark"], - langs: [ - "typescript", - "javascript", - "python", - "bash", - "json", - "html", - "css", - "markdown", - "yaml", - "sql", - "rust", - "go", - "cpp", - "c", - "java", - "csharp", - "php", - "ruby", - "swift", - "kotlin", - "diff", - "shell", - ], - }) - } - return highlighter -} - export function CodeBlockInline(props: CodeBlockInlineProps) { const { isDark } = useTheme() const [html, setHtml] = createSignal("") const [copied, setCopied] = createSignal(false) const [ready, setReady] = createSignal(false) + let highlighter: Highlighter | null = null onMount(async () => { - const hl = await getOrCreateHighlighter() + highlighter = await getSharedHighlighter() setReady(true) - updateHighlight(hl) + updateHighlight() }) - const updateHighlight = async (hl: Highlighter) => { + createEffect(() => { + if (ready()) { + updateHighlight() + } + }) + + const updateHighlight = () => { + if (!highlighter) return + if (!props.language) { setHtml(`
${escapeHtml(props.code)}
`) return } try { - const highlighted = hl.codeToHtml(props.code, { + const highlighted = highlighter.codeToHtml(props.code, { lang: props.language, theme: isDark() ? "github-dark" : "github-light", }) @@ -116,14 +91,3 @@ export function CodeBlockInline(props: CodeBlockInlineProps) { ) } - -function escapeHtml(text: string): string { - const map: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - } - return text.replace(/[&<>"']/g, (m) => map[m]) -} diff --git a/src/components/logs-view.tsx b/src/components/logs-view.tsx new file mode 100644 index 00000000..d0917856 --- /dev/null +++ b/src/components/logs-view.tsx @@ -0,0 +1,118 @@ +import { Component, For, createSignal, createEffect, Show, onMount, onCleanup } from "solid-js" +import { instances } from "../stores/instances" +import { Trash2, ChevronDown } from "lucide-solid" +import type { LogEntry } from "../types/instance" + +interface LogsViewProps { + instanceId: string +} + +const logsScrollState = new Map() + +const LogsView: Component = (props) => { + let scrollRef: HTMLDivElement | undefined + const savedState = logsScrollState.get(props.instanceId) + const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) + + const instance = () => instances().get(props.instanceId) + const logs = () => instance()?.logs ?? [] + + onMount(() => { + if (scrollRef && savedState) { + scrollRef.scrollTop = savedState.scrollTop + } + }) + + onCleanup(() => { + if (scrollRef) { + logsScrollState.set(props.instanceId, { + scrollTop: scrollRef.scrollTop, + autoScroll: autoScroll(), + }) + } + }) + + createEffect(() => { + if (autoScroll() && scrollRef && logs().length > 0) { + scrollRef.scrollTop = scrollRef.scrollHeight + } + }) + + const handleScroll = () => { + if (!scrollRef) return + + const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50 + + setAutoScroll(isAtBottom) + } + + const scrollToBottom = () => { + if (scrollRef) { + scrollRef.scrollTop = scrollRef.scrollHeight + setAutoScroll(true) + } + } + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp) + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + } + + const getLevelColor = (level: string) => { + switch (level) { + case "error": + return "text-red-600 dark:text-red-400" + case "warn": + return "text-yellow-600 dark:text-yellow-400" + case "debug": + return "text-gray-500 dark:text-gray-500" + default: + return "text-gray-900 dark:text-gray-100" + } + } + + return ( +
+
+

Server Logs

+
+ +
+ 0} + fallback={
Waiting for server output...
} + > + + {(entry) => ( +
+ {formatTime(entry.timestamp)} + {entry.message} +
+ )} +
+
+
+ + + + +
+ ) +} + +export default LogsView diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx index 450e0b10..a0db2f58 100644 --- a/src/components/markdown.tsx +++ b/src/components/markdown.tsx @@ -1,5 +1,5 @@ -import { createEffect, createSignal, onMount, Show } from "solid-js" -import { initMarkdown, renderMarkdown } from "../lib/markdown" +import { createEffect, createSignal, Show } from "solid-js" +import { renderMarkdown } from "../lib/markdown" interface MarkdownProps { content: string @@ -8,29 +8,11 @@ interface MarkdownProps { export function Markdown(props: MarkdownProps) { const [html, setHtml] = createSignal("") - const [ready, setReady] = createSignal(false) let containerRef: HTMLDivElement | undefined - onMount(async () => { - await initMarkdown(props.isDark ?? false) - setReady(true) - }) - createEffect(async () => { - if (ready()) { - const rendered = await renderMarkdown(props.content) - setHtml(rendered) - } - }) - - createEffect(async () => { - if (props.isDark !== undefined) { - await initMarkdown(props.isDark) - if (ready()) { - const rendered = await renderMarkdown(props.content) - setHtml(rendered) - } - } + const rendered = await renderMarkdown(props.content) + setHtml(rendered) }) createEffect(() => { @@ -87,9 +69,5 @@ export function Markdown(props: MarkdownProps) { } }) - return ( - Loading...}> -
- - ) + return
} diff --git a/src/index.css b/src/index.css index 477bbdd4..9914563c 100644 --- a/src/index.css +++ b/src/index.css @@ -948,6 +948,7 @@ body { cursor: pointer; color: #666; transition: all 150ms ease; + margin-left: auto; } [data-theme="dark"] .code-block-copy { diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 4d4df5d4..f5acb486 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -2,43 +2,32 @@ import { marked } from "marked" import { getHighlighter, type Highlighter } from "shiki" let highlighter: Highlighter | null = null +let highlighterPromise: Promise | null = null let currentTheme: "light" | "dark" = "light" +let isInitialized = false async function getOrCreateHighlighter() { - if (!highlighter) { - highlighter = await getHighlighter({ - themes: ["github-light", "github-dark"], - langs: [ - "typescript", - "javascript", - "python", - "bash", - "json", - "html", - "css", - "markdown", - "yaml", - "sql", - "rust", - "go", - "cpp", - "c", - "java", - "csharp", - "php", - "ruby", - "swift", - "kotlin", - "diff", - "shell", - ], - }) + if (highlighter) { + return highlighter } + + if (highlighterPromise) { + return highlighterPromise + } + + highlighterPromise = getHighlighter({ + themes: ["github-light", "github-dark"], + langs: [], + }) + + highlighter = await highlighterPromise + highlighterPromise = null return highlighter } -export async function initMarkdown(isDark: boolean) { - const hl = await getOrCreateHighlighter() +function setupRenderer(isDark: boolean) { + if (!highlighter) return + currentTheme = isDark ? "dark" : "light" marked.setOptions({ @@ -52,12 +41,12 @@ export async function initMarkdown(isDark: boolean) { const encodedCode = encodeURIComponent(code) const escapedLang = lang ? escapeHtml(lang) : "" - if (!lang) { + if (!lang || !highlighter) { return `
${escapeHtml(code)}
` } try { - const html = hl.codeToHtml(code, { + const html = highlighter.codeToHtml(code, { lang, theme: isDark ? "github-dark" : "github-light", }) @@ -79,14 +68,28 @@ export async function initMarkdown(isDark: boolean) { marked.use({ renderer }) } +export async function initMarkdown(isDark: boolean) { + await getOrCreateHighlighter() + setupRenderer(isDark) + isInitialized = true +} + +export function isMarkdownReady(): boolean { + return isInitialized && highlighter !== null +} + export async function renderMarkdown(content: string): Promise { - if (!highlighter) { + if (!isInitialized) { await initMarkdown(currentTheme === "dark") } return marked.parse(content) as Promise } -function escapeHtml(text: string): string { +export async function getSharedHighlighter(): Promise { + return getOrCreateHighlighter() +} + +export function escapeHtml(text: string): string { const map: Record = { "&": "&", "<": "<", diff --git a/src/stores/instances.ts b/src/stores/instances.ts index a9ba98ce..a23d6d6d 100644 --- a/src/stores/instances.ts +++ b/src/stores/instances.ts @@ -1,5 +1,5 @@ import { createSignal } from "solid-js" -import type { Instance } from "../types/instance" +import type { Instance, LogEntry } from "../types/instance" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { fetchSessions, fetchAgents, fetchProviders } from "./sessions" @@ -8,6 +8,8 @@ import { showSessionPicker } from "./ui" const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) +const MAX_LOG_ENTRIES = 1000 + function addInstance(instance: Instance) { setInstances((prev) => { const next = new Map(prev) @@ -40,48 +42,48 @@ function removeInstance(id: string) { } async function createInstance(folder: string): Promise { - const tempId = `temp-${Date.now()}` + const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const instance: Instance = { - id: tempId, + id, folder, port: 0, pid: 0, status: "starting", client: null, + logs: [], } addInstance(instance) try { - const { port, pid } = await window.electronAPI.createInstance(folder) + const { id: returnedId, port, pid } = await window.electronAPI.createInstance(id, folder) const client = sdkManager.createClient(port) - updateInstance(tempId, { + updateInstance(id, { port, pid, client, status: "ready", }) - setActiveInstanceId(tempId) - - sseManager.connect(tempId, port) + setActiveInstanceId(id) + sseManager.connect(id, port) try { - await fetchSessions(tempId) - await fetchAgents(tempId) - await fetchProviders(tempId) + await fetchSessions(id) + await fetchAgents(id) + await fetchProviders(id) } catch (error) { console.error("Failed to fetch initial data:", error) } - showSessionPicker(tempId) + showSessionPicker(id) - return tempId + return id } catch (error) { - updateInstance(tempId, { + updateInstance(id, { status: "error", error: error instanceof Error ? error.message : String(error), }) @@ -111,6 +113,32 @@ function getActiveInstance(): Instance | null { return id ? instances().get(id) || null : null } +function addLog(id: string, entry: LogEntry) { + setInstances((prev) => { + const next = new Map(prev) + const instance = next.get(id) + if (instance) { + const logs = [...instance.logs, entry] + if (logs.length > MAX_LOG_ENTRIES) { + logs.shift() + } + next.set(id, { ...instance, logs }) + } + return next + }) +} + +function clearLogs(id: string) { + setInstances((prev) => { + const next = new Map(prev) + const instance = next.get(id) + if (instance) { + next.set(id, { ...instance, logs: [] }) + } + return next + }) +} + export { instances, activeInstanceId, @@ -121,4 +149,6 @@ export { createInstance, stopInstance, getActiveInstance, + addLog, + clearLogs, } diff --git a/src/types/instance.ts b/src/types/instance.ts index 7c8b7d3b..6ea207b7 100644 --- a/src/types/instance.ts +++ b/src/types/instance.ts @@ -1,5 +1,11 @@ import type { OpencodeClient } from "@opencode-ai/sdk/client" +export interface LogEntry { + timestamp: number + level: "info" | "error" | "warn" | "debug" + message: string +} + export interface Instance { id: string folder: string @@ -8,10 +14,5 @@ export interface Instance { status: "starting" | "ready" | "error" | "stopped" error?: string client: OpencodeClient | null -} - -export interface LogEntry { - timestamp: number - level: "info" | "error" - message: string + logs: LogEntry[] } diff --git a/tasks/README.md b/tasks/README.md index 057c6640..bec5d03d 100644 --- a/tasks/README.md +++ b/tasks/README.md @@ -67,17 +67,17 @@ Each task file contains: ### Phase 2: Core Chat (Tasks 006-010) -- [ ] 006 - Instance & Session Tabs -- [ ] 007 - Message Display -- [ ] 008 - SSE Integration -- [ ] 009 - Prompt Input (Basic) -- [ ] 010 - Tool Call Rendering +- [x] 006 - Instance & Session Tabs +- [x] 007 - Message Display +- [x] 008 - SSE Integration +- [x] 009 - Prompt Input (Basic) +- [x] 010 - Tool Call Rendering ### Phase 3: Essential Features (Tasks 011-015) -- [ ] 011 - Agent/Model Selectors -- [ ] 012 - Markdown Rendering -- [ ] 013 - Logs Tab +- [x] 011 - Agent/Model Selectors +- [x] 012 - Markdown Rendering +- [x] 013 - Logs Tab - [ ] 014 - Error Handling - [ ] 015 - Keyboard Shortcuts diff --git a/tasks/done/013-logs-tab.md b/tasks/done/013-logs-tab.md new file mode 100644 index 00000000..eb6c8712 --- /dev/null +++ b/tasks/done/013-logs-tab.md @@ -0,0 +1,479 @@ +# Task 013: Logs Tab + +**Status:** Todo +**Estimated Time:** 2-3 hours +**Phase:** 3 - Essential Features +**Dependencies:** 006 (Instance & Session Tabs) + +## Overview + +Implement a dedicated "Logs" tab for each instance that displays real-time server logs (stdout/stderr). This provides visibility into what the OpenCode server is doing and helps with debugging. + +## Context + +Currently, server logs are captured but not displayed anywhere. Users need to see: + +- Server startup messages +- Port information +- Error messages +- Debug output +- Any other stdout/stderr from the OpenCode server + +The Logs tab should be a special tab that appears alongside session tabs and cannot be closed. + +## Requirements + +### Functional Requirements + +1. **Logs Tab Appearance** + - Appears in session tabs area (Level 2 tabs) + - Label: "Logs" + - Icon: Terminal icon (⚡ or similar) + - Non-closable (no × button) + - Always present for each instance + - Typically positioned at the end of session tabs + +2. **Log Display** + - Shows all stdout/stderr from server process + - Real-time updates as logs come in + - Scrollable content + - Auto-scroll to bottom when new logs arrive + - Manual scroll up disables auto-scroll + - Monospace font for log content + - Timestamps for each log entry + +3. **Log Entry Format** + - Timestamp (HH:MM:SS) + - Log level indicator (if available) + - Message content + - Color coding by level: + - Info: Default color + - Error: Red + - Warning: Yellow + - Debug: Gray/muted + +4. **Log Controls** + - Clear logs button + - Scroll to bottom button (when scrolled up) + - Optional: Filter by log level (post-MVP) + - Optional: Search in logs (post-MVP) + +### Technical Requirements + +1. **State Management** + - Store logs in instance state + - Structure: `{ timestamp: number, level: string, message: string }[]` + - Limit log entries to prevent memory issues (e.g., max 1000 entries) + - Old entries removed when limit reached (FIFO) + +2. **IPC Communication** + - Main process captures process stdout/stderr + - Send logs to renderer via IPC events + - Event type: `instance:log` + - Payload: `{ instanceId: string, entry: LogEntry }` + +3. **Rendering** + - Virtualize log list only if performance issues (not for MVP) + - Simple list rendering is fine for MVP + - Each log entry is a separate div + - Apply styling based on log level + +4. **Performance** + - Don't render logs when tab is not active + - Lazy render log entries (only visible ones if using virtual scrolling - not needed for MVP) + +## Implementation Steps + +### Step 1: Update Instance State + +Update `src/stores/instances.ts` to include logs: + +```typescript +interface LogEntry { + timestamp: number + level: "info" | "error" | "warn" | "debug" + message: string +} + +interface Instance { + id: string + folder: string + port: number + pid: number + status: InstanceStatus + client: OpenCodeClient + eventSource: EventSource | null + sessions: Map + activeSessionId: string | null + logs: LogEntry[] // Add this +} + +// Add log management functions +function addLog(instanceId: string, entry: LogEntry) { + const instance = instances.get(instanceId) + if (!instance) return + + instance.logs.push(entry) + + // Limit to 1000 entries + if (instance.logs.length > 1000) { + instance.logs.shift() + } +} + +function clearLogs(instanceId: string) { + const instance = instances.get(instanceId) + if (!instance) return + instance.logs = [] +} +``` + +### Step 2: Update Main Process Log Capture + +Update `electron/main/process-manager.ts` to send logs via IPC: + +```typescript +import { BrowserWindow } from "electron" + +function spawn(folder: string, mainWindow: BrowserWindow): Promise { + const proc = spawn("opencode", ["serve", "--port", "0"], { + cwd: folder, + stdio: ["ignore", "pipe", "pipe"], + }) + + const instanceId = generateId() + + // Capture stdout + proc.stdout?.on("data", (data) => { + const message = data.toString() + + // Send to renderer + mainWindow.webContents.send("instance:log", { + instanceId, + entry: { + timestamp: Date.now(), + level: "info", + message: message.trim(), + }, + }) + + // Parse port if present + const port = parsePort(message) + if (port) { + // ... existing port handling + } + }) + + // Capture stderr + proc.stderr?.on("data", (data) => { + const message = data.toString() + + mainWindow.webContents.send("instance:log", { + instanceId, + entry: { + timestamp: Date.now(), + level: "error", + message: message.trim(), + }, + }) + }) + + // ... rest of spawn logic +} +``` + +### Step 3: Update Preload Script + +Add IPC handler in `electron/preload/index.ts`: + +```typescript +contextBridge.exposeInMainWorld("electronAPI", { + // ... existing methods + + onInstanceLog: (callback: (data: { instanceId: string; entry: LogEntry }) => void) => { + ipcRenderer.on("instance:log", (_, data) => callback(data)) + }, +}) +``` + +### Step 4: Create Logs Component + +Create `src/components/logs-view.tsx`: + +```typescript +import { For, createSignal, createEffect, onMount } from 'solid-js' +import { useInstances } from '../stores/instances' + +interface LogsViewProps { + instanceId: string +} + +export function LogsView(props: LogsViewProps) { + let scrollRef: HTMLDivElement | undefined + const [autoScroll, setAutoScroll] = createSignal(true) + const instances = useInstances() + + const instance = () => instances().get(props.instanceId) + const logs = () => instance()?.logs ?? [] + + // Auto-scroll to bottom when new logs arrive + createEffect(() => { + if (autoScroll() && scrollRef) { + scrollRef.scrollTop = scrollRef.scrollHeight + } + }) + + // Handle manual scroll + const handleScroll = () => { + if (!scrollRef) return + + const isAtBottom = + scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50 + + setAutoScroll(isAtBottom) + } + + const scrollToBottom = () => { + if (scrollRef) { + scrollRef.scrollTop = scrollRef.scrollHeight + setAutoScroll(true) + } + } + + const clearLogs = () => { + // Call store method to clear logs + instances.clearLogs(props.instanceId) + } + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp) + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + const getLevelColor = (level: string) => { + switch (level) { + case 'error': return 'text-red-600 dark:text-red-400' + case 'warn': return 'text-yellow-600 dark:text-yellow-400' + case 'debug': return 'text-gray-500 dark:text-gray-500' + default: return 'text-gray-900 dark:text-gray-100' + } + } + + return ( +
+ {/* Header with controls */} +
+

+ Server Logs +

+
+ +
+
+ + {/* Logs container */} +
+ {logs().length === 0 ? ( +
+ Waiting for server output... +
+ ) : ( + + {(entry) => ( +
+ + {formatTime(entry.timestamp)} + + + {entry.message} + +
+ )} +
+ )} +
+ + {/* Scroll to bottom button */} + {!autoScroll() && ( + + )} +
+ ) +} +``` + +### Step 5: Update Session Tabs Component + +Update `src/components/session-tabs.tsx` to include Logs tab: + +```typescript +import { LogsView } from './logs-view' + +export function SessionTabs(props: { instanceId: string }) { + const sessions = () => getSessionsForInstance(props.instanceId) + const activeSession = () => getActiveSession(props.instanceId) + const [activeTab, setActiveTab] = createSignal(/* ... */) + + return ( +
+ {/* Tab headers */} +
+ {/* Session tabs */} + + {(session) => ( + + )} + + + {/* Logs tab */} + + + {/* New session button */} + +
+ + {/* Tab content */} +
+ {activeTab() === 'logs' ? ( + + ) : ( + + )} +
+
+ ) +} +``` + +### Step 6: Setup IPC Listener + +In `src/App.tsx` or wherever instances are initialized: + +```typescript +import { onMount } from "solid-js" + +onMount(() => { + // Listen for log events from main process + window.electronAPI.onInstanceLog((data) => { + const { instanceId, entry } = data + instances.addLog(instanceId, entry) + }) +}) +``` + +### Step 7: Add Initial Server Logs + +When instance starts, add a startup log: + +```typescript +function createInstance(folder: string) { + const instanceId = generateId() + + // Add initial log + instances.addLog(instanceId, { + timestamp: Date.now(), + level: "info", + message: `Starting OpenCode server for ${folder}...`, + }) + + // ... spawn server +} +``` + +### Step 8: Test Logs Display + +1. Start an instance +2. Switch to Logs tab +3. Verify startup messages appear +4. Verify real-time updates +5. Test auto-scroll behavior +6. Test clear button +7. Test manual scroll disables auto-scroll +8. Test scroll to bottom button + +## Acceptance Criteria + +- [ ] Logs tab appears for each instance +- [ ] Logs tab has terminal icon +- [ ] Logs tab cannot be closed +- [ ] Server stdout displays in real-time +- [ ] Server stderr displays in real-time +- [ ] Logs have timestamps +- [ ] Error logs are red +- [ ] Warning logs are yellow +- [ ] Auto-scroll works when at bottom +- [ ] Manual scroll disables auto-scroll +- [ ] Scroll to bottom button appears when scrolled up +- [ ] Clear button removes all logs +- [ ] Logs are limited to 1000 entries +- [ ] Monospace font used for log content +- [ ] Empty state shows when no logs + +## Testing Checklist + +- [ ] Test with normal server startup +- [ ] Test with server errors (e.g., port in use) +- [ ] Test with rapid log output (stress test) +- [ ] Test switching between session and logs tab +- [ ] Test clearing logs +- [ ] Test auto-scroll with new logs +- [ ] Test manual scroll behavior +- [ ] Test logs persist when switching instances +- [ ] Test logs cleared when instance closes +- [ ] Test very long log messages (wrapping) + +## Notes + +- For MVP, don't implement log filtering or search +- Keep log entry limit reasonable (1000 entries) +- Don't virtualize unless performance issues +- Consider adding log levels based on OpenCode server output format +- May need to parse ANSI color codes if server uses them + +## Future Enhancements (Post-MVP) + +- Filter logs by level (info, error, warn, debug) +- Search within logs +- Export logs to file +- Copy log entry on click +- Follow mode toggle (auto-scroll on/off) +- Parse and highlight errors/stack traces +- ANSI color code support +- Log level indicators with icons +- Timestamps toggle +- Word wrap toggle