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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
}
|
|
||||||
return text.replace(/[&<>"']/g, (m) => map[m])
|
|
||||||
}
|
|
||||||
|
|||||||
118
src/components/logs-view.tsx
Normal file
118
src/components/logs-view.tsx
Normal 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
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> = {
|
||||||
"&": "&",
|
"&": "&",
|
||||||
"<": "<",
|
"<": "<",
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
479
tasks/done/013-logs-tab.md
Normal 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
|
||||||
Reference in New Issue
Block a user