feat(ui): add runtime logger and replace console usage

This commit is contained in:
Shantur Rathore
2025-12-05 15:07:49 +00:00
parent 49143bd049
commit 971abe24d7
44 changed files with 406 additions and 138 deletions

View File

@@ -17,6 +17,7 @@ import type {
WorkspaceEventPayload,
WorkspaceEventType,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
@@ -38,15 +39,15 @@ function buildEventsUrl(base: string | undefined, path: string): string {
return path
}
const HTTP_PREFIX = "[HTTP]"
const httpLogger = getLogger("api")
const sseLogger = getLogger("sse")
function logHttp(message: string, context?: Record<string, unknown>) {
if (context) {
console.log(`${HTTP_PREFIX} ${message}`, context)
httpLogger.info(message, context)
return
}
console.log(`${HTTP_PREFIX} ${message}`)
httpLogger.info(message)
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
@@ -186,18 +187,18 @@ export const serverApi = {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
},
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
console.log(`[SSE] Connecting to ${EVENTS_URL}`)
sseLogger.info(`Connecting to ${EVENTS_URL}`)
const source = new EventSource(EVENTS_URL)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload
onEvent(payload)
} catch (error) {
console.error("[SSE] Failed to parse event", error)
sseLogger.error("Failed to parse event", error)
}
}
source.onerror = () => {
console.warn("[SSE] EventSource error, closing stream")
sseLogger.warn("EventSource error, closing stream")
onError?.()
}
return source

View File

@@ -2,6 +2,9 @@ import type { Command } from "./commands"
import type { Command as SDKCommand } from "@opencode-ai/sdk"
import { showAlertDialog } from "../stores/alerts"
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
import { getLogger } from "./logger"
const log = getLogger("actions")
export function commandRequiresArguments(template?: string): boolean {
if (!template) return false
@@ -47,7 +50,7 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
try {
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) {
console.error("Failed to run custom command:", error)
log.error("Failed to run custom command", error)
showAlertDialog("Failed to run custom command. Check the console for details.", {
title: "Command failed",
variant: "error",

View File

@@ -8,6 +8,9 @@ import { keyboardRegistry } from "../keyboard-registry"
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
import type { Instance } from "../../types/instance"
import { getLogger } from "../logger"
const log = getLogger("actions")
interface UseAppLifecycleOptions {
setEscapeInDebounce: (value: boolean) => void
@@ -115,9 +118,9 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
try {
await abortSession(instance.id, sessionId)
console.log("Session aborted successfully")
log.info("Session aborted successfully", { instanceId: instance.id, sessionId })
} catch (error) {
console.error("Failed to abort session:", error)
log.error("Failed to abort session", error)
}
},
() => {

View File

@@ -17,6 +17,9 @@ import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { cleanupBlankSessions } from "../../stores/session-state"
import { getLogger } from "../logger"
const log = getLogger("actions")
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
@@ -236,15 +239,16 @@ export function useCommands(options: UseCommandsOptions) {
modelID: session.model.modelId,
},
})
} catch (error: unknown) {
} catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
console.error("Failed to compact session:", error)
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, {
title: "Compact failed",
variant: "error",
})
}
},
})
@@ -322,12 +326,13 @@ export function useCommands(options: UseCommandsOptions) {
}
}
} catch (error) {
console.error("Failed to revert message:", error)
log.error("Failed to revert message", error)
showAlertDialog("Failed to revert message", {
title: "Undo failed",
variant: "error",
})
}
},
})
@@ -503,7 +508,7 @@ export function useCommands(options: UseCommandsOptions) {
category: "System",
keywords: ["/help", "shortcuts", "help"],
action: () => {
console.log("Show help modal (not implemented)")
log.info("Show help modal (not implemented)")
},
})
}
@@ -513,11 +518,11 @@ export function useCommands(options: UseCommandsOptions) {
const result = command.action?.()
if (result instanceof Promise) {
void result.catch((error) => {
console.error("Command execution failed:", error)
log.error("Command execution failed", error)
})
}
} catch (error) {
console.error("Command execution failed:", error)
log.error("Command execution failed", error)
}
}

View File

@@ -0,0 +1,128 @@
import debug from "debug"
export type LoggerNamespace = "sse" | "api" | "session" | "actions"
interface Logger {
log: (...args: unknown[]) => void
info: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
}
interface NamespaceState {
name: LoggerNamespace
enabled: boolean
}
const KNOWN_NAMESPACES: LoggerNamespace[] = ["sse", "api", "session", "actions"]
const STORAGE_KEY = "opencode:logger:namespaces"
const namespaceLoggers = new Map<LoggerNamespace, Logger>()
const enabledNamespaces = new Set<LoggerNamespace>()
const rawConsole = typeof globalThis !== "undefined" ? globalThis.console : undefined
function applyEnabledNamespaces(): void {
if (enabledNamespaces.size === 0) {
debug.disable()
} else {
debug.enable(Array.from(enabledNamespaces).join(","))
}
}
function persistEnabledNamespaces(): void {
if (typeof window === "undefined" || !window?.localStorage) return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(enabledNamespaces)))
} catch (error) {
rawConsole?.warn?.("Failed to persist logger namespaces", error)
}
}
function hydrateNamespacesFromStorage(): void {
if (typeof window === "undefined" || !window?.localStorage) return
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (!stored) return
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return
for (const name of parsed) {
if (KNOWN_NAMESPACES.includes(name as LoggerNamespace)) {
enabledNamespaces.add(name as LoggerNamespace)
}
}
} catch (error) {
rawConsole?.warn?.("Failed to hydrate logger namespaces", error)
}
}
hydrateNamespacesFromStorage()
applyEnabledNamespaces()
function buildLogger(namespace: LoggerNamespace): Logger {
const base = debug(namespace)
const baseLogger: (...args: any[]) => void = base
const formatAndLog = (level: string, args: any[]) => {
baseLogger(level, ...args)
}
return {
log: (...args: any[]) => baseLogger(...args),
info: (...args: any[]) => baseLogger(...args),
warn: (...args: any[]) => formatAndLog("[warn]", args),
error: (...args: any[]) => formatAndLog("[error]", args),
}
}
function getLogger(namespace: LoggerNamespace): Logger {
if (!KNOWN_NAMESPACES.includes(namespace)) {
throw new Error(`Unknown logger namespace: ${namespace}`)
}
if (!namespaceLoggers.has(namespace)) {
namespaceLoggers.set(namespace, buildLogger(namespace))
}
return namespaceLoggers.get(namespace)!
}
function listLoggerNamespaces(): NamespaceState[] {
return KNOWN_NAMESPACES.map((name) => ({ name, enabled: enabledNamespaces.has(name) }))
}
function enableLogger(namespace: LoggerNamespace): void {
if (!KNOWN_NAMESPACES.includes(namespace)) {
throw new Error(`Unknown logger namespace: ${namespace}`)
}
if (enabledNamespaces.has(namespace)) return
enabledNamespaces.add(namespace)
persistEnabledNamespaces()
applyEnabledNamespaces()
}
function disableLogger(namespace: LoggerNamespace): void {
if (!KNOWN_NAMESPACES.includes(namespace)) {
throw new Error(`Unknown logger namespace: ${namespace}`)
}
if (!enabledNamespaces.has(namespace)) return
enabledNamespaces.delete(namespace)
persistEnabledNamespaces()
applyEnabledNamespaces()
}
function disableAllLoggers(): void {
enabledNamespaces.clear()
persistEnabledNamespaces()
applyEnabledNamespaces()
}
function enableAllLoggers(): void {
KNOWN_NAMESPACES.forEach((namespace) => enabledNamespaces.add(namespace))
persistEnabledNamespaces()
applyEnabledNamespaces()
}
export {
getLogger,
listLoggerNamespaces,
enableLogger,
disableLogger,
enableAllLoggers,
disableAllLoggers,
}

View File

@@ -1,5 +1,8 @@
import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger"
const log = getLogger("actions")
let highlighter: Highlighter | null = null
let highlighterPromise: Promise<Highlighter> | null = null
@@ -71,7 +74,7 @@ function triggerLanguageListeners() {
try {
listener()
} catch (error) {
console.error("Error in language listener:", error)
log.error("Error in language listener", error)
}
}
}

View File

@@ -1,4 +1,7 @@
import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
export async function restartCli(): Promise<boolean> {
try {
@@ -20,7 +23,7 @@ export async function restartCli(): Promise<boolean> {
return false
}
} catch (error) {
console.error("Failed to restart CLI", error)
log.error("Failed to restart CLI", error)
return false
}

View File

@@ -1,4 +1,7 @@
import type { NativeDialogOptions } from "../native-functions"
import { getLogger } from "../../logger"
const log = getLogger("actions")
interface ElectronDialogResult {
canceled?: boolean
@@ -33,7 +36,7 @@ export async function openElectronNativeDialog(options: NativeDialogOptions): Pr
const result = await api.openDialog(options)
return coerceFirstPath(result)
} catch (error) {
console.error("[native] electron dialog failed", error)
log.error("[native] electron dialog failed", error)
return null
}
}

View File

@@ -1,4 +1,7 @@
import type { NativeDialogOptions } from "../native-functions"
import { getLogger } from "../../logger"
const log = getLogger("actions")
interface TauriDialogModule {
open?: (
@@ -49,7 +52,7 @@ export async function openTauriNativeDialog(options: NativeDialogOptions): Promi
return response
} catch (error) {
console.error("[native] tauri dialog failed", error)
log.error("[native] tauri dialog failed", error)
return null
}
}

View File

@@ -1,3 +1,5 @@
import { getLogger } from "./logger"
export type HostRuntime = "electron" | "tauri" | "web"
export type PlatformKind = "desktop" | "mobile"
@@ -61,6 +63,8 @@ function detectPlatform(): PlatformKind {
return "desktop"
}
const log = getLogger("actions")
let cachedEnv: RuntimeEnvironment | null = null
export function detectRuntimeEnvironment(): RuntimeEnvironment {
@@ -71,9 +75,8 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
host: detectHost(),
platform: detectPlatform(),
}
if (typeof console !== "undefined") {
const message = `[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`
console.info(message)
if (typeof window !== "undefined") {
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
}
return cachedEnv
}

View File

@@ -1,16 +1,17 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
import { serverApi } from "./api-client"
import { getLogger } from "./logger"
const RETRY_BASE_DELAY = 1000
const RETRY_MAX_DELAY = 10000
const SSE_PREFIX = "[SSE]"
const log = getLogger("sse")
function logSse(message: string, context?: Record<string, unknown>) {
if (context) {
console.log(`${SSE_PREFIX} ${message}`, context)
log.info(message, context)
return
}
console.log(`${SSE_PREFIX} ${message}`)
log.info(message)
}
class ServerEvents {

View File

@@ -20,6 +20,9 @@ import type {
InstanceStreamStatus,
WorkspaceEventPayload,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
const log = getLogger("sse")
type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
@@ -80,11 +83,11 @@ class SSEManager {
private handleEvent(instanceId: string, event: SSEEvent | InstanceStreamEvent): void {
if (!event || typeof event !== "object" || typeof (event as { type?: unknown }).type !== "string") {
console.warn("[SSE] Dropping malformed event", event)
log.warn("Dropping malformed event", event)
return
}
console.log("[SSE] Received event:", event.type, event)
log.info("Received event", { type: event.type, event })
switch (event.type) {
case "message.updated":
@@ -124,7 +127,7 @@ class SSEManager {
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break
default:
console.warn("[SSE] Unknown event type:", event.type)
log.warn("Unknown SSE event type", { type: event.type })
}
}

View File

@@ -1,6 +1,9 @@
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
import { serverApi } from "./api-client"
import { serverEvents } from "./server-events"
import { getLogger } from "./logger"
const log = getLogger("actions")
export type ConfigData = AppConfig
@@ -19,7 +22,7 @@ function isDeepEqual(a: unknown, b: unknown): boolean {
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch (error) {
console.warn("Failed to compare config objects", error)
log.warn("Failed to compare config objects", error)
}
}