489 lines
16 KiB
TypeScript
489 lines
16 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 QwenOAuthCallback from "./pages/QwenOAuthCallback"
|
|
|
|
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 {
|
|
hasInstances,
|
|
isSelectingFolder,
|
|
setIsSelectingFolder,
|
|
showFolderSelection,
|
|
setShowFolderSelection,
|
|
showFolderSelectionOnStart,
|
|
setShowFolderSelectionOnStart,
|
|
} 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,
|
|
flushSessionPersistence,
|
|
updateSessionAgent,
|
|
updateSessionModel,
|
|
} from "./stores/sessions"
|
|
|
|
const log = getLogger("actions")
|
|
|
|
const App: Component = () => {
|
|
const { isDark } = useTheme()
|
|
const {
|
|
preferences,
|
|
recordWorkspaceLaunch,
|
|
toggleShowThinkingBlocks,
|
|
toggleShowTimelineTools,
|
|
toggleAutoCleanupBlankSessions,
|
|
toggleUsageMetrics,
|
|
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 shouldShowFolderSelection = () => !hasInstances() || showFolderSelectionOnStart()
|
|
|
|
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()
|
|
})
|
|
|
|
createEffect(() => {
|
|
instances()
|
|
hasInstances()
|
|
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
|
})
|
|
|
|
onMount(() => {
|
|
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 "Failed to launch workspace"
|
|
}
|
|
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)
|
|
setShowFolderSelectionOnStart(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(
|
|
"Stop OpenCode instance? This will stop the server.",
|
|
{
|
|
title: "Stop instance",
|
|
variant: "warning",
|
|
confirmLabel: "Stop",
|
|
cancelLabel: "Keep running",
|
|
},
|
|
)
|
|
|
|
if (!confirmed) return
|
|
|
|
clearActiveParentSession(instanceId)
|
|
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
|
|
}
|
|
|
|
try {
|
|
await flushSessionPersistence(instanceId)
|
|
} catch (error) {
|
|
log.error("Failed to flush session persistence before closing", error)
|
|
}
|
|
|
|
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,
|
|
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?.()
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Check if this is OAuth callback
|
|
const isOAuthCallback = window.location.pathname === '/auth/qwen/callback'
|
|
|
|
if (isOAuthCallback) {
|
|
return <QwenOAuthCallback />
|
|
}
|
|
|
|
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-md p-6 flex flex-col gap-6">
|
|
<div>
|
|
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
|
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
|
binary from Advanced Settings.
|
|
</Dialog.Description>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</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">
|
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="flex justify-end gap-2">
|
|
<Show when={launchError()?.missingBinary}>
|
|
<button
|
|
type="button"
|
|
class="selector-button selector-button-secondary"
|
|
onClick={handleLaunchErrorAdvanced}
|
|
>
|
|
Open Advanced Settings
|
|
</button>
|
|
</Show>
|
|
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</Dialog.Content>
|
|
</div>
|
|
</Dialog.Portal>
|
|
</Dialog>
|
|
<div class="h-screen w-screen flex flex-col">
|
|
<Show
|
|
when={shouldShowFolderSelection()}
|
|
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)
|
|
setShowFolderSelectionOnStart(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="Close (Esc)"
|
|
>
|
|
<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
|