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
This commit is contained in:
Shantur Rathore
2025-10-23 11:14:35 +01:00
parent b836086978
commit 3c5c4755b8
13 changed files with 779 additions and 157 deletions

View File

@@ -18,9 +18,9 @@ function generateId(): string {
} }
export function setupInstanceIPC(mainWindow: BrowserWindow) { export function setupInstanceIPC(mainWindow: BrowserWindow) {
ipcMain.handle("instance:create", async (event, folder: string) => { processManager.setMainWindow(mainWindow)
const id = generateId()
ipcMain.handle("instance:create", async (event, id: string, folder: string) => {
const instance: Instance = { const instance: Instance = {
id, id,
folder, folder,
@@ -32,7 +32,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
instances.set(id, instance) instances.set(id, instance)
try { try {
const { pid, port } = await processManager.spawn(folder) const { pid, port } = await processManager.spawn(folder, id)
instance.port = port instance.port = port
instance.pid = pid instance.pid = pid
@@ -48,7 +48,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
}) })
} }
return { port, pid } return { id, port, pid }
} catch (error) { } catch (error) {
instance.status = "error" instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error) instance.error = error instanceof Error ? error.message : String(error)

View File

@@ -1,5 +1,5 @@
import { spawn, ChildProcess } from "child_process" import { spawn, ChildProcess } from "child_process"
import { app } from "electron" import { app, BrowserWindow } from "electron"
import { existsSync, statSync } from "fs" import { existsSync, statSync } from "fs"
import { execSync } from "child_process" import { execSync } from "child_process"
@@ -15,17 +15,48 @@ interface ProcessMeta {
startTime: number startTime: number
childProcess: ChildProcess childProcess: ChildProcess
logs: string[] logs: string[]
instanceId: string
} }
class ProcessManager { class ProcessManager {
private processes = new Map<number, ProcessMeta>() private processes = new Map<number, ProcessMeta>()
private mainWindow: BrowserWindow | null = null
async spawn(folder: string): Promise<ProcessInfo> { 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<ProcessInfo> {
this.validateFolder(folder) this.validateFolder(folder)
this.validateOpenCodeBinary() this.validateOpenCodeBinary()
this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder}...`)
return new Promise((resolve, reject) => { 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, cwd: folder,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: process.env, env: process.env,
@@ -34,6 +65,7 @@ class ProcessManager {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
child.kill("SIGKILL") child.kill("SIGKILL")
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
reject(new Error("Server startup timeout (10s exceeded)")) reject(new Error("Server startup timeout (10s exceeded)"))
}, 10000) }, 10000)
@@ -49,6 +81,10 @@ class ProcessManager {
stdoutBuffer = lines.pop() || "" stdoutBuffer = lines.pop() || ""
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue
this.sendLog(instanceId, "info", line)
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/) const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
if (portMatch && !portFound) { if (portMatch && !portFound) {
portFound = true portFound = true
@@ -62,13 +98,13 @@ class ProcessManager {
startTime: Date.now(), startTime: Date.now(),
childProcess: child, childProcess: child,
logs: [line], logs: [line],
instanceId,
} }
this.processes.set(child.pid!, meta) this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port }) resolve({ pid: child.pid!, port })
} }
const logEntry = { timestamp: Date.now(), level: "info", message: line }
const meta = this.processes.get(child.pid!) const meta = this.processes.get(child.pid!)
if (meta) { if (meta) {
meta.logs.push(line) meta.logs.push(line)
@@ -84,7 +120,10 @@ class ProcessManager {
stderrBuffer = lines.pop() || "" stderrBuffer = lines.pop() || ""
for (const line of lines) { 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!) const meta = this.processes.get(child.pid!)
if (meta) { if (meta) {
meta.logs.push(line) meta.logs.push(line)

View File

@@ -2,20 +2,23 @@ import { contextBridge, ipcRenderer } from "electron"
export interface ElectronAPI { export interface ElectronAPI {
selectFolder: () => Promise<string | null> selectFolder: () => Promise<string | null>
createInstance: (folder: string) => Promise<{ port: number; pid: number }> createInstance: (id: string, folder: string) => Promise<{ id: string; port: number; pid: number }>
stopInstance: (pid: number) => Promise<void> stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number }) => void) => void onInstanceStarted: (callback: (data: { id: string; port: number; pid: number }) => void) => void
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
onInstanceStopped: (callback: (data: { id: string }) => void) => void onInstanceStopped: (callback: (data: { id: string }) => void) => void
onInstanceLog: ( 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 ) => void
onNewInstance: (callback: () => void) => void onNewInstance: (callback: () => void) => void
} }
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"), 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), stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => { onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data)) ipcRenderer.on("instance:started", (_, data) => callback(data))

View File

@@ -6,6 +6,8 @@ import InstanceTabs from "./components/instance-tabs"
import SessionTabs from "./components/session-tabs" import SessionTabs from "./components/session-tabs"
import MessageStream from "./components/message-stream" import MessageStream from "./components/message-stream"
import PromptInput from "./components/prompt-input" import PromptInput from "./components/prompt-input"
import LogsView from "./components/logs-view"
import { initMarkdown } from "./lib/markdown"
import { import {
hasInstances, hasInstances,
isSelectingFolder, isSelectingFolder,
@@ -23,6 +25,7 @@ import {
setActiveInstanceId, setActiveInstanceId,
stopInstance, stopInstance,
getActiveInstance, getActiveInstance,
addLog,
} from "./stores/instances" } from "./stores/instances"
import { import {
getSessions, getSessions,
@@ -173,6 +176,8 @@ const App: Component = () => {
} }
onMount(() => { onMount(() => {
initMarkdown(false).catch(console.error)
setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession) setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession)
window.electronAPI.onNewInstance(() => { window.electronAPI.onNewInstance(() => {
@@ -193,6 +198,10 @@ const App: Component = () => {
console.log("Instance stopped:", id) console.log("Instance stopped:", id)
updateInstance(id, { status: "stopped" }) updateInstance(id, { status: "stopped" })
}) })
window.electronAPI.onInstanceLog(({ id, entry }) => {
addLog(id, entry)
})
}) })
return ( return (
@@ -255,10 +264,7 @@ const App: Component = () => {
</Show> </Show>
} }
> >
<div class="p-4 text-gray-600"> <LogsView instanceId={instance().id} />
<p class="font-semibold mb-2">Server Logs</p>
<p class="text-sm">Log viewer will be implemented in Task 013</p>
</div>
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@@ -1,67 +1,42 @@
import { createSignal, onMount, Show } from "solid-js" import { createSignal, onMount, Show, createEffect } from "solid-js"
import { getHighlighter, type Highlighter } from "shiki" import type { Highlighter } from "shiki"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
interface CodeBlockInlineProps { interface CodeBlockInlineProps {
code: string code: string
language?: 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) { export function CodeBlockInline(props: CodeBlockInlineProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
let highlighter: Highlighter | null = null
onMount(async () => { onMount(async () => {
const hl = await getOrCreateHighlighter() highlighter = await getSharedHighlighter()
setReady(true) setReady(true)
updateHighlight(hl) updateHighlight()
}) })
const updateHighlight = async (hl: Highlighter) => { createEffect(() => {
if (ready()) {
updateHighlight()
}
})
const updateHighlight = () => {
if (!highlighter) return
if (!props.language) { if (!props.language) {
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`) setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
return return
} }
try { try {
const highlighted = hl.codeToHtml(props.code, { const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language, lang: props.language,
theme: isDark() ? "github-dark" : "github-light", theme: isDark() ? "github-dark" : "github-light",
}) })
@@ -116,14 +91,3 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
</Show> </Show>
) )
} }
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<>"']/g, (m) => map[m])
}

View File

@@ -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<string, { scrollTop: number; autoScroll: boolean }>()
const LogsView: Component<LogsViewProps> = (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 (
<div class="flex flex-col h-full bg-white dark:bg-gray-900">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Server Logs</h3>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-gray-900 font-mono text-xs leading-relaxed"
>
<Show
when={logs().length > 0}
fallback={<div class="text-gray-500 dark:text-gray-500 text-center py-8">Waiting for server output...</div>}
>
<For each={logs()}>
{(entry) => (
<div class="flex gap-3 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 px-2 -mx-2 rounded">
<span class="text-gray-500 dark:text-gray-500 select-none shrink-0">{formatTime(entry.timestamp)}</span>
<span class={`${getLevelColor(entry.level)} break-all`}>{entry.message}</span>
</div>
)}
</For>
</Show>
</div>
<Show when={!autoScroll()}>
<button
onClick={scrollToBottom}
class="absolute bottom-6 right-6 px-3 py-2 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center gap-1 text-sm"
>
<ChevronDown class="w-4 h-4" />
Scroll to bottom
</button>
</Show>
</div>
)
}
export default LogsView

View File

@@ -1,5 +1,5 @@
import { createEffect, createSignal, onMount, Show } from "solid-js" import { createEffect, createSignal, Show } from "solid-js"
import { initMarkdown, renderMarkdown } from "../lib/markdown" import { renderMarkdown } from "../lib/markdown"
interface MarkdownProps { interface MarkdownProps {
content: string content: string
@@ -8,29 +8,11 @@ interface MarkdownProps {
export function Markdown(props: MarkdownProps) { export function Markdown(props: MarkdownProps) {
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
const [ready, setReady] = createSignal(false)
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
onMount(async () => {
await initMarkdown(props.isDark ?? false)
setReady(true)
})
createEffect(async () => { createEffect(async () => {
if (ready()) { const rendered = await renderMarkdown(props.content)
const rendered = await renderMarkdown(props.content) setHtml(rendered)
setHtml(rendered)
}
})
createEffect(async () => {
if (props.isDark !== undefined) {
await initMarkdown(props.isDark)
if (ready()) {
const rendered = await renderMarkdown(props.content)
setHtml(rendered)
}
}
}) })
createEffect(() => { createEffect(() => {
@@ -87,9 +69,5 @@ export function Markdown(props: MarkdownProps) {
} }
}) })
return ( return <div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
<Show when={ready()} fallback={<div class="text-gray-500">Loading...</div>}>
<div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
</Show>
)
} }

View File

@@ -948,6 +948,7 @@ body {
cursor: pointer; cursor: pointer;
color: #666; color: #666;
transition: all 150ms ease; transition: all 150ms ease;
margin-left: auto;
} }
[data-theme="dark"] .code-block-copy { [data-theme="dark"] .code-block-copy {

View File

@@ -2,43 +2,32 @@ import { marked } from "marked"
import { getHighlighter, type Highlighter } from "shiki" import { getHighlighter, type Highlighter } from "shiki"
let highlighter: Highlighter | null = null let highlighter: Highlighter | null = null
let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light" let currentTheme: "light" | "dark" = "light"
let isInitialized = false
async function getOrCreateHighlighter() { async function getOrCreateHighlighter() {
if (!highlighter) { if (highlighter) {
highlighter = await getHighlighter({ return highlighter
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 (highlighterPromise) {
return highlighterPromise
}
highlighterPromise = getHighlighter({
themes: ["github-light", "github-dark"],
langs: [],
})
highlighter = await highlighterPromise
highlighterPromise = null
return highlighter return highlighter
} }
export async function initMarkdown(isDark: boolean) { function setupRenderer(isDark: boolean) {
const hl = await getOrCreateHighlighter() if (!highlighter) return
currentTheme = isDark ? "dark" : "light" currentTheme = isDark ? "dark" : "light"
marked.setOptions({ marked.setOptions({
@@ -52,12 +41,12 @@ export async function initMarkdown(isDark: boolean) {
const encodedCode = encodeURIComponent(code) const encodedCode = encodeURIComponent(code)
const escapedLang = lang ? escapeHtml(lang) : "" const escapedLang = lang ? escapeHtml(lang) : ""
if (!lang) { if (!lang || !highlighter) {
return `<div class="markdown-code-block" data-language="" data-code="${encodedCode}"><pre><code>${escapeHtml(code)}</code></pre></div>` return `<div class="markdown-code-block" data-language="" data-code="${encodedCode}"><pre><code>${escapeHtml(code)}</code></pre></div>`
} }
try { try {
const html = hl.codeToHtml(code, { const html = highlighter.codeToHtml(code, {
lang, lang,
theme: isDark ? "github-dark" : "github-light", theme: isDark ? "github-dark" : "github-light",
}) })
@@ -79,14 +68,28 @@ export async function initMarkdown(isDark: boolean) {
marked.use({ renderer }) 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<string> { export async function renderMarkdown(content: string): Promise<string> {
if (!highlighter) { if (!isInitialized) {
await initMarkdown(currentTheme === "dark") await initMarkdown(currentTheme === "dark")
} }
return marked.parse(content) as Promise<string> return marked.parse(content) as Promise<string>
} }
function escapeHtml(text: string): string { export async function getSharedHighlighter(): Promise<Highlighter> {
return getOrCreateHighlighter()
}
export function escapeHtml(text: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
"&": "&amp;", "&": "&amp;",
"<": "&lt;", "<": "&lt;",

View File

@@ -1,5 +1,5 @@
import { createSignal } from "solid-js" 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 { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager" import { sseManager } from "../lib/sse-manager"
import { fetchSessions, fetchAgents, fetchProviders } from "./sessions" import { fetchSessions, fetchAgents, fetchProviders } from "./sessions"
@@ -8,6 +8,8 @@ import { showSessionPicker } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map()) const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null) const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
const MAX_LOG_ENTRIES = 1000
function addInstance(instance: Instance) { function addInstance(instance: Instance) {
setInstances((prev) => { setInstances((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -40,48 +42,48 @@ function removeInstance(id: string) {
} }
async function createInstance(folder: string): Promise<string> { async function createInstance(folder: string): Promise<string> {
const tempId = `temp-${Date.now()}` const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const instance: Instance = { const instance: Instance = {
id: tempId, id,
folder, folder,
port: 0, port: 0,
pid: 0, pid: 0,
status: "starting", status: "starting",
client: null, client: null,
logs: [],
} }
addInstance(instance) addInstance(instance)
try { 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) const client = sdkManager.createClient(port)
updateInstance(tempId, { updateInstance(id, {
port, port,
pid, pid,
client, client,
status: "ready", status: "ready",
}) })
setActiveInstanceId(tempId) setActiveInstanceId(id)
sseManager.connect(id, port)
sseManager.connect(tempId, port)
try { try {
await fetchSessions(tempId) await fetchSessions(id)
await fetchAgents(tempId) await fetchAgents(id)
await fetchProviders(tempId) await fetchProviders(id)
} catch (error) { } catch (error) {
console.error("Failed to fetch initial data:", error) console.error("Failed to fetch initial data:", error)
} }
showSessionPicker(tempId) showSessionPicker(id)
return tempId return id
} catch (error) { } catch (error) {
updateInstance(tempId, { updateInstance(id, {
status: "error", status: "error",
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}) })
@@ -111,6 +113,32 @@ function getActiveInstance(): Instance | null {
return id ? instances().get(id) || null : 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 { export {
instances, instances,
activeInstanceId, activeInstanceId,
@@ -121,4 +149,6 @@ export {
createInstance, createInstance,
stopInstance, stopInstance,
getActiveInstance, getActiveInstance,
addLog,
clearLogs,
} }

View File

@@ -1,5 +1,11 @@
import type { OpencodeClient } from "@opencode-ai/sdk/client" import type { OpencodeClient } from "@opencode-ai/sdk/client"
export interface LogEntry {
timestamp: number
level: "info" | "error" | "warn" | "debug"
message: string
}
export interface Instance { export interface Instance {
id: string id: string
folder: string folder: string
@@ -8,10 +14,5 @@ export interface Instance {
status: "starting" | "ready" | "error" | "stopped" status: "starting" | "ready" | "error" | "stopped"
error?: string error?: string
client: OpencodeClient | null client: OpencodeClient | null
} logs: LogEntry[]
export interface LogEntry {
timestamp: number
level: "info" | "error"
message: string
} }

View File

@@ -67,17 +67,17 @@ Each task file contains:
### Phase 2: Core Chat (Tasks 006-010) ### Phase 2: Core Chat (Tasks 006-010)
- [ ] 006 - Instance & Session Tabs - [x] 006 - Instance & Session Tabs
- [ ] 007 - Message Display - [x] 007 - Message Display
- [ ] 008 - SSE Integration - [x] 008 - SSE Integration
- [ ] 009 - Prompt Input (Basic) - [x] 009 - Prompt Input (Basic)
- [ ] 010 - Tool Call Rendering - [x] 010 - Tool Call Rendering
### Phase 3: Essential Features (Tasks 011-015) ### Phase 3: Essential Features (Tasks 011-015)
- [ ] 011 - Agent/Model Selectors - [x] 011 - Agent/Model Selectors
- [ ] 012 - Markdown Rendering - [x] 012 - Markdown Rendering
- [ ] 013 - Logs Tab - [x] 013 - Logs Tab
- [ ] 014 - Error Handling - [ ] 014 - Error Handling
- [ ] 015 - Keyboard Shortcuts - [ ] 015 - Keyboard Shortcuts

479
tasks/done/013-logs-tab.md Normal file
View File

@@ -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<string, Session>
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<ProcessInfo> {
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 (
<div class="flex flex-col h-full">
{/* Header with controls */}
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Server Logs
</h3>
<div class="flex gap-2">
<button
onClick={clearLogs}
class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
Clear
</button>
</div>
</div>
{/* Logs container */}
<div
ref={scrollRef}
onScroll={handleScroll}
class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-gray-900 font-mono text-xs"
>
{logs().length === 0 ? (
<div class="text-gray-500 dark:text-gray-500 text-center py-8">
Waiting for server output...
</div>
) : (
<For each={logs()}>
{(entry) => (
<div class="flex gap-2 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800">
<span class="text-gray-500 dark:text-gray-500 select-none">
{formatTime(entry.timestamp)}
</span>
<span class={getLevelColor(entry.level)}>
{entry.message}
</span>
</div>
)}
</For>
)}
</div>
{/* Scroll to bottom button */}
{!autoScroll() && (
<button
onClick={scrollToBottom}
class="absolute bottom-4 right-4 px-3 py-2 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700"
>
Scroll to bottom
</button>
)}
</div>
)
}
```
### 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<string | 'logs'>(/* ... */)
return (
<div class="flex flex-col h-full">
{/* Tab headers */}
<div class="flex items-center border-b border-gray-200 dark:border-gray-700">
{/* Session tabs */}
<For each={sessions()}>
{(session) => (
<button
onClick={() => setActiveTab(session.id)}
class={/* ... */}
>
{session.title || 'Untitled'}
</button>
)}
</For>
{/* Logs tab */}
<button
onClick={() => setActiveTab('logs')}
class={`px-4 py-2 text-sm ${
activeTab() === 'logs'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 dark:text-gray-400'
}`}
>
Logs
</button>
{/* New session button */}
<button class="px-3 py-2 text-gray-500 hover:text-gray-700">
+
</button>
</div>
{/* Tab content */}
<div class="flex-1 overflow-hidden">
{activeTab() === 'logs' ? (
<LogsView instanceId={props.instanceId} />
) : (
<SessionView sessionId={activeTab()} />
)}
</div>
</div>
)
}
```
### 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