Let the 'Unable to launch OpenCode' dialog grow up to 80vh and keep only the error output pane scrollable so longer stderr is visible without cramped nested scrolling.
497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
|
import { Dialog } from "@kobalte/core/dialog"
|
|
import { Toaster } from "solid-toast"
|
|
import AlertDialog from "./components/alert-dialog"
|
|
import FolderSelectionView from "./components/folder-selection-view"
|
|
import { showConfirmDialog } from "./stores/alerts"
|
|
import InstanceTabs from "./components/instance-tabs"
|
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
|
import InstanceShell from "./components/instance/instance-shell2"
|
|
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
|
import { initMarkdown } from "./lib/markdown"
|
|
import { initGithubStars } from "./stores/github-stars"
|
|
|
|
import { useTheme } from "./lib/theme"
|
|
import { useCommands } from "./lib/hooks/use-commands"
|
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
|
import { getLogger } from "./lib/logger"
|
|
import { initReleaseNotifications } from "./stores/releases"
|
|
import { runtimeEnv } from "./lib/runtime-env"
|
|
import { useI18n } from "./lib/i18n"
|
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
|
import {
|
|
hasInstances,
|
|
isSelectingFolder,
|
|
setIsSelectingFolder,
|
|
showFolderSelection,
|
|
setShowFolderSelection,
|
|
} from "./stores/ui"
|
|
import { useConfig } from "./stores/preferences"
|
|
import {
|
|
createInstance,
|
|
instances,
|
|
activeInstanceId,
|
|
setActiveInstanceId,
|
|
stopInstance,
|
|
getActiveInstance,
|
|
disconnectedInstance,
|
|
acknowledgeDisconnectedInstance,
|
|
} from "./stores/instances"
|
|
import {
|
|
getSessions,
|
|
activeSessionId,
|
|
setActiveParentSession,
|
|
clearActiveParentSession,
|
|
createSession,
|
|
fetchSessions,
|
|
updateSessionAgent,
|
|
updateSessionModel,
|
|
} from "./stores/sessions"
|
|
|
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
|
|
|
const log = getLogger("actions")
|
|
|
|
const App: Component = () => {
|
|
const { isDark } = useTheme()
|
|
const { t } = useI18n()
|
|
const {
|
|
preferences,
|
|
recordWorkspaceLaunch,
|
|
toggleShowThinkingBlocks,
|
|
toggleShowTimelineTools,
|
|
toggleAutoCleanupBlankSessions,
|
|
toggleUsageMetrics,
|
|
togglePromptSubmitOnEnter,
|
|
setDiffViewMode,
|
|
setToolOutputExpansion,
|
|
setDiagnosticsExpansion,
|
|
setThinkingBlocksExpansion,
|
|
} = useConfig()
|
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
|
interface LaunchErrorState {
|
|
message: string
|
|
binaryPath: string
|
|
missingBinary: boolean
|
|
}
|
|
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
|
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
|
|
|
const updateInstanceTabBarHeight = () => {
|
|
if (typeof document === "undefined") return
|
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
|
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
|
}
|
|
|
|
createEffect(() => {
|
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
|
})
|
|
|
|
createEffect(() => {
|
|
initReleaseNotifications()
|
|
})
|
|
|
|
const shouldHoldWakeLock = createMemo(() => {
|
|
const map = instances()
|
|
for (const id of map.keys()) {
|
|
const status = getInstanceSessionIndicatorStatus(id)
|
|
if (status !== "idle") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
|
|
createEffect(() => {
|
|
const hold = shouldHoldWakeLock()
|
|
void setWakeLockDesired(hold)
|
|
})
|
|
|
|
onCleanup(() => {
|
|
void setWakeLockDesired(false)
|
|
})
|
|
|
|
createEffect(() => {
|
|
instances()
|
|
hasInstances()
|
|
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
|
})
|
|
|
|
onMount(() => {
|
|
void initGithubStars()
|
|
updateInstanceTabBarHeight()
|
|
const handleResize = () => updateInstanceTabBarHeight()
|
|
window.addEventListener("resize", handleResize)
|
|
onCleanup(() => window.removeEventListener("resize", handleResize))
|
|
})
|
|
|
|
const activeInstance = createMemo(() => getActiveInstance())
|
|
const activeSessionIdForInstance = createMemo(() => {
|
|
const instance = activeInstance()
|
|
if (!instance) return null
|
|
return activeSessionId().get(instance.id) || null
|
|
})
|
|
|
|
const launchErrorPath = () => {
|
|
const value = launchError()?.binaryPath
|
|
if (!value) return "opencode"
|
|
return value.trim() || "opencode"
|
|
}
|
|
|
|
const launchErrorMessage = () => launchError()?.message ?? ""
|
|
|
|
const formatLaunchErrorMessage = (error: unknown): string => {
|
|
if (!error) {
|
|
return t("app.launchError.fallbackMessage")
|
|
}
|
|
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
if (parsed && typeof parsed.error === "string") {
|
|
return parsed.error
|
|
}
|
|
} catch {
|
|
// ignore JSON parse errors
|
|
}
|
|
return raw
|
|
}
|
|
|
|
const isMissingBinaryMessage = (message: string): boolean => {
|
|
const normalized = message.toLowerCase()
|
|
return (
|
|
normalized.includes("opencode binary not found") ||
|
|
normalized.includes("binary not found") ||
|
|
normalized.includes("no such file or directory") ||
|
|
normalized.includes("binary is not executable") ||
|
|
normalized.includes("enoent")
|
|
)
|
|
}
|
|
|
|
const clearLaunchError = () => setLaunchError(null)
|
|
|
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
|
if (!folderPath) {
|
|
return
|
|
}
|
|
setIsSelectingFolder(true)
|
|
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
|
try {
|
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
|
clearLaunchError()
|
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
|
setShowFolderSelection(false)
|
|
setIsAdvancedSettingsOpen(false)
|
|
|
|
log.info("Created instance", {
|
|
instanceId,
|
|
port: instances().get(instanceId)?.port,
|
|
})
|
|
} catch (error) {
|
|
const message = formatLaunchErrorMessage(error)
|
|
const missingBinary = isMissingBinaryMessage(message)
|
|
setLaunchError({
|
|
message,
|
|
binaryPath: selectedBinary,
|
|
missingBinary,
|
|
})
|
|
log.error("Failed to create instance", error)
|
|
} finally {
|
|
setIsSelectingFolder(false)
|
|
}
|
|
}
|
|
|
|
function handleLaunchErrorClose() {
|
|
clearLaunchError()
|
|
}
|
|
|
|
function handleLaunchErrorAdvanced() {
|
|
clearLaunchError()
|
|
setIsAdvancedSettingsOpen(true)
|
|
}
|
|
|
|
function handleNewInstanceRequest() {
|
|
if (hasInstances()) {
|
|
setShowFolderSelection(true)
|
|
}
|
|
}
|
|
|
|
async function handleDisconnectedInstanceClose() {
|
|
try {
|
|
await acknowledgeDisconnectedInstance()
|
|
} catch (error) {
|
|
log.error("Failed to finalize disconnected instance", error)
|
|
}
|
|
}
|
|
|
|
async function handleCloseInstance(instanceId: string) {
|
|
const confirmed = await showConfirmDialog(
|
|
t("app.stopInstance.confirmMessage"),
|
|
{
|
|
title: t("app.stopInstance.title"),
|
|
variant: "warning",
|
|
confirmLabel: t("app.stopInstance.confirmLabel"),
|
|
cancelLabel: t("app.stopInstance.cancelLabel"),
|
|
},
|
|
)
|
|
|
|
if (!confirmed) return
|
|
|
|
await stopInstance(instanceId)
|
|
}
|
|
|
|
async function handleNewSession(instanceId: string) {
|
|
try {
|
|
const session = await createSession(instanceId)
|
|
setActiveParentSession(instanceId, session.id)
|
|
} catch (error) {
|
|
log.error("Failed to create session", error)
|
|
}
|
|
}
|
|
|
|
async function handleCloseSession(instanceId: string, sessionId: string) {
|
|
const sessions = getSessions(instanceId)
|
|
const session = sessions.find((s) => s.id === sessionId)
|
|
|
|
if (!session) {
|
|
return
|
|
}
|
|
|
|
const parentSessionId = session.parentId ?? session.id
|
|
const parentSession = sessions.find((s) => s.id === parentSessionId)
|
|
|
|
if (!parentSession || parentSession.parentId !== null) {
|
|
return
|
|
}
|
|
|
|
clearActiveParentSession(instanceId)
|
|
|
|
try {
|
|
await fetchSessions(instanceId)
|
|
} catch (error) {
|
|
log.error("Failed to refresh sessions after closing", error)
|
|
}
|
|
}
|
|
|
|
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
|
if (!instanceId || !sessionId || sessionId === "info") return
|
|
await updateSessionAgent(instanceId, sessionId, agent)
|
|
}
|
|
|
|
const handleSidebarModelChange = async (
|
|
instanceId: string,
|
|
sessionId: string,
|
|
model: { providerId: string; modelId: string },
|
|
) => {
|
|
if (!instanceId || !sessionId || sessionId === "info") return
|
|
await updateSessionModel(instanceId, sessionId, model)
|
|
}
|
|
|
|
const { commands: paletteCommands, executeCommand } = useCommands({
|
|
preferences,
|
|
toggleAutoCleanupBlankSessions,
|
|
toggleShowThinkingBlocks,
|
|
toggleShowTimelineTools,
|
|
toggleUsageMetrics,
|
|
togglePromptSubmitOnEnter,
|
|
setDiffViewMode,
|
|
setToolOutputExpansion,
|
|
setDiagnosticsExpansion,
|
|
setThinkingBlocksExpansion,
|
|
handleNewInstanceRequest,
|
|
handleCloseInstance,
|
|
handleNewSession,
|
|
handleCloseSession,
|
|
getActiveInstance: activeInstance,
|
|
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
|
})
|
|
|
|
useAppLifecycle({
|
|
setEscapeInDebounce,
|
|
handleNewInstanceRequest,
|
|
handleCloseInstance,
|
|
handleNewSession,
|
|
handleCloseSession,
|
|
showFolderSelection,
|
|
setShowFolderSelection,
|
|
getActiveInstance: activeInstance,
|
|
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
|
})
|
|
|
|
// Listen for Tauri menu events
|
|
onMount(() => {
|
|
if (runtimeEnv.host === "tauri") {
|
|
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
|
if (tauriBridge?.event) {
|
|
let unlistenMenu: (() => void) | null = null
|
|
|
|
tauriBridge.event.listen("menu:newInstance", () => {
|
|
handleNewInstanceRequest()
|
|
}).then((unlisten) => {
|
|
unlistenMenu = unlisten
|
|
}).catch((error) => {
|
|
log.error("Failed to listen for menu:newInstance event", error)
|
|
})
|
|
|
|
onCleanup(() => {
|
|
unlistenMenu?.()
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
return (
|
|
<>
|
|
<InstanceDisconnectedModal
|
|
open={Boolean(disconnectedInstance())}
|
|
folder={disconnectedInstance()?.folder}
|
|
reason={disconnectedInstance()?.reason}
|
|
onClose={handleDisconnectedInstanceClose}
|
|
/>
|
|
|
|
<Dialog open={Boolean(launchError())} modal>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay class="modal-overlay" />
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
|
|
<div>
|
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
|
{t("app.launchError.description")}
|
|
</Dialog.Description>
|
|
</div>
|
|
|
|
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
|
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
|
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
|
</div>
|
|
|
|
<Show when={launchErrorMessage()}>
|
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
|
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-2">
|
|
<Show when={launchError()?.missingBinary}>
|
|
<button
|
|
type="button"
|
|
class="selector-button selector-button-secondary"
|
|
onClick={handleLaunchErrorAdvanced}
|
|
>
|
|
{t("app.launchError.openAdvancedSettings")}
|
|
</button>
|
|
</Show>
|
|
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
|
{t("app.launchError.close")}
|
|
</button>
|
|
</div>
|
|
</Dialog.Content>
|
|
</div>
|
|
</Dialog.Portal>
|
|
</Dialog>
|
|
<div class="h-screen w-screen flex flex-col">
|
|
<Show
|
|
when={!hasInstances()}
|
|
fallback={
|
|
<>
|
|
<InstanceTabs
|
|
instances={instances()}
|
|
activeInstanceId={activeInstanceId()}
|
|
onSelect={setActiveInstanceId}
|
|
onClose={handleCloseInstance}
|
|
onNew={handleNewInstanceRequest}
|
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
/>
|
|
|
|
<For each={Array.from(instances().values())}>
|
|
{(instance) => {
|
|
const isActiveInstance = () => activeInstanceId() === instance.id
|
|
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
|
return (
|
|
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
|
<InstanceMetadataProvider instance={instance}>
|
|
<InstanceShell
|
|
instance={instance}
|
|
escapeInDebounce={escapeInDebounce()}
|
|
paletteCommands={paletteCommands}
|
|
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
|
onNewSession={() => handleNewSession(instance.id)}
|
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
|
onExecuteCommand={executeCommand}
|
|
tabBarOffset={instanceTabBarHeight()}
|
|
/>
|
|
</InstanceMetadataProvider>
|
|
|
|
</div>
|
|
)
|
|
|
|
}}
|
|
</For>
|
|
|
|
</>
|
|
}
|
|
>
|
|
<FolderSelectionView
|
|
onSelectFolder={handleSelectFolder}
|
|
isLoading={isSelectingFolder()}
|
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
/>
|
|
</Show>
|
|
|
|
<Show when={showFolderSelection()}>
|
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
|
<div class="w-full h-full relative">
|
|
<button
|
|
onClick={() => {
|
|
setShowFolderSelection(false)
|
|
setIsAdvancedSettingsOpen(false)
|
|
clearLaunchError()
|
|
}}
|
|
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
title={t("app.launchError.closeTitle")}
|
|
>
|
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
<FolderSelectionView
|
|
onSelectFolder={handleSelectFolder}
|
|
isLoading={isSelectingFolder()}
|
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
|
|
|
<AlertDialog />
|
|
|
|
<Toaster
|
|
position="top-right"
|
|
gutter={16}
|
|
toastOptions={{
|
|
duration: 8000,
|
|
className: "bg-transparent border-none shadow-none p-0",
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
|
|
export default App
|