diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index 013caa11..1400cf5a 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -35,13 +35,13 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { instances.set(id, instance) try { - const { pid, port } = await processManager.spawn(folder, id) + const { pid, port, binaryPath } = await processManager.spawn(folder, id) instance.port = port instance.pid = pid instance.status = "ready" - mainWindow.webContents.send("instance:started", { id, port, pid }) + mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath }) const meta = processManager.getAllProcesses().get(pid) if (meta) { @@ -51,7 +51,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { }) } - return { id, port, pid } + return { id, port, pid, binaryPath } } catch (error) { instance.status = "error" instance.error = error instanceof Error ? error.message : String(error) diff --git a/electron/main/process-manager.ts b/electron/main/process-manager.ts index 0a86d355..d20d8654 100644 --- a/electron/main/process-manager.ts +++ b/electron/main/process-manager.ts @@ -6,6 +6,7 @@ import { execSync } from "child_process" export interface ProcessInfo { pid: number port: number + binaryPath: string } interface ProcessMeta { @@ -51,7 +52,7 @@ class ProcessManager { async spawn(folder: string, instanceId: string): Promise { this.validateFolder(folder) - this.validateOpenCodeBinary() + const binaryPath = this.validateOpenCodeBinary() this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder}...`) @@ -102,7 +103,7 @@ class ProcessManager { } this.processes.set(child.pid!, meta) - resolve({ pid: child.pid!, port }) + resolve({ pid: child.pid!, port, binaryPath }) } const meta = this.processes.get(child.pid!) @@ -208,10 +209,12 @@ class ProcessManager { } } - private validateOpenCodeBinary(): void { + private validateOpenCodeBinary(): string { const command = process.platform === "win32" ? "where opencode" : "which opencode" try { - execSync(command, { stdio: "pipe" }) + const output = execSync(command, { stdio: "pipe", encoding: "utf-8" }) + const paths = output.trim().split("\n") + return paths[0].trim() } catch { throw new Error( "opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli", diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 589ea9f9..c3defe89 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -2,9 +2,9 @@ import { contextBridge, ipcRenderer } from "electron" export interface ElectronAPI { selectFolder: () => Promise - createInstance: (id: string, folder: string) => Promise<{ id: string; port: number; pid: number }> + createInstance: (id: string, folder: string) => Promise<{ id: string; port: number; pid: number; binaryPath: string }> stopInstance: (pid: number) => Promise - onInstanceStarted: (callback: (data: { id: string; port: number; pid: number }) => void) => void + onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void onInstanceError: (callback: (data: { id: string; error: string }) => void) => void onInstanceStopped: (callback: (data: { id: string }) => void) => void onInstanceLog: ( diff --git a/src/App.tsx b/src/App.tsx index 82311e4e..47845b6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { Component, onMount, onCleanup, Show, createMemo, createEffect } from "s import type { Session } from "./types/session" import type { Attachment } from "./types/attachment" import EmptyState from "./components/empty-state" -import SessionPicker from "./components/session-picker" +import InstanceWelcomeView from "./components/instance-welcome-view" import CommandPalette from "./components/command-palette" import InstanceTabs from "./components/instance-tabs" import SessionTabs from "./components/session-tabs" @@ -12,15 +12,7 @@ import LogsView from "./components/logs-view" import { initMarkdown } from "./lib/markdown" import { createCommandRegistry } from "./lib/commands" import type { Command } from "./lib/commands" -import { - hasInstances, - isSelectingFolder, - setIsSelectingFolder, - setHasInstances, - sessionPickerInstance, - hideSessionPicker, - showSessionPicker, -} from "./stores/ui" +import { hasInstances, isSelectingFolder, setIsSelectingFolder, setHasInstances } from "./stores/ui" import { createInstance, instances, @@ -187,7 +179,6 @@ const App: Component = () => { } clearActiveParentSession(instanceId) - showSessionPicker(instanceId) } function setupCommands() { @@ -592,9 +583,9 @@ const App: Component = () => { handleSelectFolder() }) - window.electronAPI.onInstanceStarted(({ id, port, pid }) => { - console.log("Instance started:", { id, port, pid }) - updateInstance(id, { port, pid, status: "ready" }) + window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => { + console.log("Instance started:", { id, port, pid, binaryPath }) + updateInstance(id, { port, pid, status: "ready", binaryPath }) }) window.electronAPI.onInstanceError(({ id, error }) => { @@ -629,17 +620,7 @@ const App: Component = () => { {(instance) => ( <> - 0} - fallback={ -
-
-

No parent session selected

-

Select or create a parent session to begin

-
-
- } - > + 0} fallback={}> { - - {(instanceId) => } - - = (props) => { + const [selectedAgent, setSelectedAgent] = createSignal("") + const [isCreating, setIsCreating] = createSignal(false) + const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true) + const [selectedIndex, setSelectedIndex] = createSignal(0) + const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions") + + const parentSessions = () => getParentSessions(props.instance.id) + const agentList = () => agents().get(props.instance.id) || [] + const metadata = () => props.instance.metadata + + createEffect(() => { + const list = agentList() + if (list.length > 0 && !selectedAgent()) { + setSelectedAgent(list[0].name) + } + }) + + createEffect(() => { + const sessions = parentSessions() + if (sessions.length === 0) { + setFocusMode("new-session") + setSelectedIndex(0) + } else { + setFocusMode("sessions") + setSelectedIndex(0) + } + }) + + onMount(async () => { + await loadInstanceMetadata() + }) + + async function loadInstanceMetadata() { + if (!props.instance.client) return + + setIsLoadingMetadata(true) + try { + const [projectResult, mcpResult] = await Promise.allSettled([ + props.instance.client.project.current(), + props.instance.client.mcp.status(), + ]) + + const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined + const mcpStatus = mcpResult.status === "fulfilled" ? mcpResult.value.data : undefined + + const { updateInstance } = await import("../stores/instances") + updateInstance(props.instance.id, { + metadata: { + project, + mcpStatus, + version: "0.15.8", + }, + }) + } catch (error) { + console.error("Failed to load instance metadata:", error) + } finally { + setIsLoadingMetadata(false) + } + } + + function scrollToIndex(index: number) { + const element = document.querySelector(`[data-session-index="${index}"]`) + if (element) { + element.scrollIntoView({ block: "nearest", behavior: "auto" }) + } + } + + function handleKeyDown(e: KeyboardEvent) { + const sessions = parentSessions() + + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleNewSession() + return + } + + if (sessions.length === 0) return + + if (e.key === "ArrowDown") { + e.preventDefault() + const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1) + setSelectedIndex(newIndex) + setFocusMode("sessions") + scrollToIndex(newIndex) + } else if (e.key === "ArrowUp") { + e.preventDefault() + const newIndex = Math.max(selectedIndex() - 1, 0) + setSelectedIndex(newIndex) + setFocusMode("sessions") + scrollToIndex(newIndex) + } else if (e.key === "PageDown") { + e.preventDefault() + const pageSize = 5 + const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1) + setSelectedIndex(newIndex) + setFocusMode("sessions") + scrollToIndex(newIndex) + } else if (e.key === "PageUp") { + e.preventDefault() + const pageSize = 5 + const newIndex = Math.max(selectedIndex() - pageSize, 0) + setSelectedIndex(newIndex) + setFocusMode("sessions") + scrollToIndex(newIndex) + } else if (e.key === "Home") { + e.preventDefault() + setSelectedIndex(0) + setFocusMode("sessions") + scrollToIndex(0) + } else if (e.key === "End") { + e.preventDefault() + const newIndex = sessions.length - 1 + setSelectedIndex(newIndex) + setFocusMode("sessions") + scrollToIndex(newIndex) + } else if (e.key === "Enter") { + e.preventDefault() + handleEnterKey() + } + } + + async function handleEnterKey() { + const sessions = parentSessions() + const index = selectedIndex() + + if (index < sessions.length) { + await handleSessionSelect(sessions[index].id) + } + } + + onMount(() => { + window.addEventListener("keydown", handleKeyDown) + onCleanup(() => { + window.removeEventListener("keydown", handleKeyDown) + }) + }) + + function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days}d ago` + if (hours > 0) return `${hours}h ago` + if (minutes > 0) return `${minutes}m ago` + return "just now" + } + + function formatTimestamp(timestamp: number): string { + return new Date(timestamp).toLocaleString() + } + + async function handleSessionSelect(sessionId: string) { + setActiveParentSession(props.instance.id, sessionId) + } + + async function handleNewSession() { + if (isCreating() || agentList().length === 0) return + + setIsCreating(true) + try { + const session = await createSession(props.instance.id, selectedAgent()) + setActiveParentSession(props.instance.id, session.id) + } catch (error) { + console.error("Failed to create session:", error) + } finally { + setIsCreating(false) + } + } + + function parseMcpStatus(status: unknown): Array<{ name: string; status: "running" | "stopped" | "error" }> { + if (!status || typeof status !== "object") return [] + + try { + if (Array.isArray(status)) { + return status.map((s) => ({ + name: s.name || "Unknown", + status: s.status || "stopped", + })) + } + + const obj = status as Record + return Object.entries(obj).map(([name, data]) => { + const serverData = data as { status?: string } + return { + name, + status: (serverData?.status as "running" | "stopped" | "error") || "stopped", + } + }) + } catch { + return [] + } + } + + const mcpServers = () => { + const status = metadata()?.mcpStatus + return parseMcpStatus(status) + } + + return ( +
+
+
+ 0} + fallback={ +
+
+ + + +
+

No Previous Sessions

+

Create a new session below to get started

+
+ } + > +
+
+

Resume Session

+

+ {parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available +

+
+
+ + {(session, index) => ( + + )} + +
+
+
+ +
+
+

Start New Session

+

Create a fresh conversation with your chosen agent

+
+
+ 0} fallback={
Loading agents...
}> +
+
+ + +
+ + +
+
+
+
+
+ +
+
+
+

Instance Information

+
+
+
+
Folder
+
+ {props.instance.folder} +
+
+ + + {(project) => ( + <> +
+
Project
+
+ {project().id} +
+
+ + +
+
+ Version Control +
+
+ + + + {project().vcs} +
+
+
+ + )} +
+ + +
+
OpenCode Version
+
+ v{metadata()?.version} +
+
+
+ + +
+
Binary Path
+
+ {props.instance.binaryPath} +
+
+
+ + 0}> +
+
MCP Servers
+
+ + {(server) => ( +
+ {server.name} +
+ } + > +
+ + } + > +
+ +
+
+ )} + +
+
+ + + +
+
+ + + + + Loading... +
+
+
+ +
+
Server
+
+
+ Port: + {props.instance.port} +
+
+ PID: + {props.instance.pid} +
+
+ Status: + +
+ {props.instance.status} + +
+
+
+
+
+
+
+ +
+
+
+ + + Navigate +
+
+ PgUp + PgDn + Jump +
+
+ Home + End + First/Last +
+
+ Enter + Resume +
+
+ Cmd+Enter + New Session +
+
+
+
+ ) +} + +export default InstanceWelcomeView diff --git a/src/stores/instances.ts b/src/stores/instances.ts index a23d6d6d..707e61fd 100644 --- a/src/stores/instances.ts +++ b/src/stores/instances.ts @@ -3,7 +3,6 @@ import type { Instance, LogEntry } from "../types/instance" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { fetchSessions, fetchAgents, fetchProviders } from "./sessions" -import { showSessionPicker } from "./ui" const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) @@ -57,7 +56,7 @@ async function createInstance(folder: string): Promise { addInstance(instance) try { - const { id: returnedId, port, pid } = await window.electronAPI.createInstance(id, folder) + const { id: returnedId, port, pid, binaryPath } = await window.electronAPI.createInstance(id, folder) const client = sdkManager.createClient(port) @@ -66,6 +65,7 @@ async function createInstance(folder: string): Promise { pid, client, status: "ready", + binaryPath, }) setActiveInstanceId(id) @@ -79,8 +79,6 @@ async function createInstance(folder: string): Promise { console.error("Failed to fetch initial data:", error) } - showSessionPicker(id) - return id } catch (error) { updateInstance(id, { diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 9ae79352..f7867eeb 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -3,19 +3,10 @@ import { createSignal } from "solid-js" const [hasInstances, setHasInstances] = createSignal(false) const [selectedFolder, setSelectedFolder] = createSignal(null) const [isSelectingFolder, setIsSelectingFolder] = createSignal(false) -const [sessionPickerInstance, setSessionPickerInstance] = createSignal(null) const [instanceTabOrder, setInstanceTabOrder] = createSignal([]) const [sessionTabOrder, setSessionTabOrder] = createSignal>(new Map()) -function showSessionPicker(instanceId: string) { - setSessionPickerInstance(instanceId) -} - -function hideSessionPicker() { - setSessionPickerInstance(null) -} - function reorderInstanceTabs(newOrder: string[]) { setInstanceTabOrder(newOrder) } @@ -35,9 +26,6 @@ export { setSelectedFolder, isSelectingFolder, setIsSelectingFolder, - sessionPickerInstance, - showSessionPicker, - hideSessionPicker, instanceTabOrder, setInstanceTabOrder, sessionTabOrder, diff --git a/src/types/instance.ts b/src/types/instance.ts index 6ea207b7..446ccaf7 100644 --- a/src/types/instance.ts +++ b/src/types/instance.ts @@ -6,6 +6,27 @@ export interface LogEntry { message: string } +export interface ProjectInfo { + id: string + worktree: string + vcs?: "git" + time: { + created: number + initialized?: number + } +} + +export interface McpServerStatus { + name: string + status: "running" | "stopped" | "error" +} + +export interface InstanceMetadata { + project?: ProjectInfo + mcpStatus?: unknown + version?: string +} + export interface Instance { id: string folder: string @@ -15,4 +36,6 @@ export interface Instance { error?: string client: OpencodeClient | null logs: LogEntry[] + metadata?: InstanceMetadata + binaryPath?: string }