fix: restore complete source code and fix launchers
- Copy complete source code packages from original CodeNomad project - Add root package.json with npm workspace configuration - Include electron-app, server, ui, tauri-app, and opencode-config packages - Fix Launch-Windows.bat and Launch-Dev-Windows.bat to work with correct npm scripts - Fix Launch-Unix.sh to work with correct npm scripts - Launchers now correctly call npm run dev:electron which launches Electron app
This commit is contained in:
466
packages/ui/src/App.tsx
Normal file
466
packages/ui/src/App.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
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 { 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,
|
||||
} 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"
|
||||
|
||||
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 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)
|
||||
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
|
||||
|
||||
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,
|
||||
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-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={!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="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
|
||||
Reference in New Issue
Block a user