feat(sidecars): add proxied sidecar tabs (#279)
## Summary - add SideCar support across the server and UI, including proxied tabs, picker/settings flows, and websocket-aware proxying - unify top-level tab handling so workspace instances and SideCars share the same tab model and navigation flows - limit SideCars to port-based services only, removing server-managed process control from the final API and UI --------- Co-authored-by: Shantur <shantur@Mac.home> Co-authored-by: Shantur <shantur@Shanturs-MacBook-Pro-M5.local>
This commit is contained in:
@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
|
||||
import { SideCarView } from "./components/sidecar-view"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { showAlertDialog } from "./stores/alerts"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
|
||||
import {
|
||||
createInstance,
|
||||
instances,
|
||||
activeInstanceId,
|
||||
setActiveInstanceId,
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
} from "./stores/instances"
|
||||
@@ -53,6 +52,22 @@ import {
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
import { openSettings } from "./stores/settings-screen"
|
||||
import {
|
||||
closeSidecarTab,
|
||||
ensureSidecarsLoaded,
|
||||
openSidecarTab,
|
||||
} from "./stores/sidecars"
|
||||
import {
|
||||
activeAppTab,
|
||||
activeAppTabId,
|
||||
appTabs,
|
||||
ensureActiveAppTab,
|
||||
getAdjacentAppTabId,
|
||||
getAppTabById,
|
||||
selectAppTab,
|
||||
selectInstanceTab,
|
||||
selectSidecarTab,
|
||||
} from "./stores/app-tabs"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -77,6 +92,7 @@ const App: Component = () => {
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
|
||||
|
||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||
@@ -206,8 +222,7 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
instances()
|
||||
hasInstances()
|
||||
appTabs()
|
||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||
})
|
||||
|
||||
@@ -219,7 +234,15 @@ const App: Component = () => {
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
createEffect(() => {
|
||||
appTabs()
|
||||
ensureActiveAppTab()
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => {
|
||||
const tab = activeAppTab()
|
||||
return tab?.kind === "instance" ? tab.instance : null
|
||||
})
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return null
|
||||
@@ -244,6 +267,7 @@ const App: Component = () => {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
selectInstanceTab(instanceId)
|
||||
setShowFolderSelection(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
@@ -270,8 +294,27 @@ const App: Component = () => {
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
if (hasInstances()) {
|
||||
setShowFolderSelection(true)
|
||||
setShowFolderSelection(true)
|
||||
}
|
||||
|
||||
function handleOpenSidecarPicker() {
|
||||
setSidecarPickerOpen(true)
|
||||
void ensureSidecarsLoaded()
|
||||
}
|
||||
|
||||
async function handleOpenSidecar(sidecarId: string) {
|
||||
try {
|
||||
const tab = await openSidecarTab(sidecarId)
|
||||
selectSidecarTab(tab.token)
|
||||
setShowFolderSelection(false)
|
||||
setSidecarPickerOpen(false)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
showAlertDialog(message, {
|
||||
variant: "error",
|
||||
title: t("sidecars.open.errorTitle"),
|
||||
})
|
||||
log.error("Failed to open SideCar", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +375,23 @@ const App: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseAppTab(tabId: string) {
|
||||
const tab = getAppTabById(tabId)
|
||||
if (!tab) return
|
||||
|
||||
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
|
||||
|
||||
if (tab.kind === "instance") {
|
||||
await handleCloseInstance(tab.instance.id)
|
||||
} else {
|
||||
closeSidecarTab(tab.sidecarTab.token)
|
||||
}
|
||||
|
||||
if (!getAppTabById(tabId)) {
|
||||
ensureActiveAppTab(fallbackTabId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||
if (!instanceId || !sessionId || sessionId === "info") return
|
||||
await updateSessionAgent(instanceId, sessionId, agent)
|
||||
@@ -361,6 +421,7 @@ const App: Component = () => {
|
||||
setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
@@ -371,6 +432,7 @@ const App: Component = () => {
|
||||
useAppLifecycle({
|
||||
setEscapeInDebounce,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
@@ -470,52 +532,60 @@ const App: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
when={appTabs().length === 0}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
tabs={appTabs()}
|
||||
activeTabId={activeAppTabId()}
|
||||
onSelect={selectAppTab}
|
||||
onClose={(tabId) => void handleCloseAppTab(tabId)}
|
||||
onNew={handleNewInstanceRequest}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<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" }}
|
||||
data-instance-id={instance.id}
|
||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||
data-instance-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
isActiveInstance={isActiveInstance()}
|
||||
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={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
<For each={appTabs()}>
|
||||
{(tab) => {
|
||||
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
|
||||
return tab.kind === "instance" ? (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={tab.instance.id}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-kind={tab.kind}
|
||||
data-tab-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={tab.instance}>
|
||||
<InstanceShell
|
||||
instance={tab.instance}
|
||||
isActiveInstance={isVisible()}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(tab.instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-kind={tab.kind}
|
||||
data-tab-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<SideCarView tab={tab.sidecarTab} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -525,6 +595,7 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
onOpenSidecar={handleOpenSidecarPicker}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -534,6 +605,7 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
onOpenSidecar={handleOpenSidecarPicker}
|
||||
onClose={() => {
|
||||
setShowFolderSelection(false)
|
||||
clearLaunchError()
|
||||
@@ -544,6 +616,7 @@ const App: Component = () => {
|
||||
</Show>
|
||||
|
||||
<SettingsScreen />
|
||||
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type HomeTab = "local" | "servers"
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
onOpenSidecar?: () => void
|
||||
isLoading?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -845,32 +846,43 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="panel-body flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openServerDialog}
|
||||
class="button-primary w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onOpenSidecar?.()}
|
||||
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
<span>{t("folderSelection.sidecars.button")}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openServerDialog}
|
||||
class="button-primary w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OpenCode settings section */}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
import type { AppTabRecord } from "../stores/app-tabs"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
tabs: AppTabRecord[]
|
||||
activeTabId: string | null
|
||||
onSelect: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
|
||||
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
<For each={props.tabs}>
|
||||
{(tab) =>
|
||||
tab.kind === "instance" ? (
|
||||
<InstanceTab
|
||||
instance={tab.instance}
|
||||
active={tab.id === props.activeTabId}
|
||||
onSelect={() => props.onSelect(tab.id)}
|
||||
onClose={() => props.onClose(tab.id)}
|
||||
/>
|
||||
) : (
|
||||
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
|
||||
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
|
||||
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
|
||||
</button>
|
||||
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-strip-spacer" />
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<Show when={props.tabs.length > 1}>
|
||||
<div class="tab-shortcuts">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
import { selectInstanceTab } from "../stores/app-tabs"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -130,7 +130,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
||||
}
|
||||
|
||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
setActiveInstanceId(location.instanceId)
|
||||
selectInstanceTab(location.instanceId)
|
||||
const parentToActivate = location.parentId ?? location.sessionId
|
||||
setActiveParentSession(location.instanceId, parentToActivate)
|
||||
if (location.parentId) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
|
||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
|
||||
import { createMemo, For, type Component } from "solid-js"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
|
||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const { t } = useI18n()
|
||||
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
])
|
||||
|
||||
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
|
||||
return <RemoteAccessSettingsSection />
|
||||
case "speech":
|
||||
return <SpeechSettingsSection />
|
||||
case "sidecars":
|
||||
return <SideCarsSettingsSection />
|
||||
case "opencode":
|
||||
return <OpenCodeSettingsSection />
|
||||
case "appearance":
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js"
|
||||
import { Globe, Loader2, Plus, Trash2 } from "lucide-solid"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars"
|
||||
|
||||
function deriveSidecarId(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
}
|
||||
|
||||
export const SideCarsSettingsSection: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const [name, setName] = createSignal("")
|
||||
const [port, setPort] = createSignal("3000")
|
||||
const [insecure, setInsecure] = createSignal(false)
|
||||
const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip")
|
||||
const [busyId, setBusyId] = createSignal<string | null>(null)
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [formError, setFormError] = createSignal<string | null>(null)
|
||||
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||
|
||||
onMount(() => {
|
||||
void ensureSidecarsLoaded()
|
||||
})
|
||||
|
||||
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||
const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar")
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmedName = name().trim()
|
||||
const nextPort = Number(port())
|
||||
if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
|
||||
setFormError(t("sidecars.form.validation"))
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
await serverApi.createSidecar({
|
||||
kind: "port",
|
||||
name: trimmedName,
|
||||
port: nextPort,
|
||||
insecure: insecure(),
|
||||
prefixMode: prefixMode(),
|
||||
})
|
||||
setName("")
|
||||
setPort("3000")
|
||||
setInsecure(false)
|
||||
setPrefixMode("strip")
|
||||
} catch (error) {
|
||||
setFormError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setBusyId(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await serverApi.deleteSidecar(id)
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="settings-section-stack">
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div class="settings-card-heading-with-icon">
|
||||
<Globe class="settings-card-heading-icon" />
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.section.sidecars.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.section.sidecars.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-card-content">
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.name")}</div>
|
||||
<div class="settings-toggle-caption">{t("sidecars.basePath")}: <code>/sidecars/{derivedId()}</code></div>
|
||||
</div>
|
||||
<input
|
||||
class="selector-input w-full max-w-xs"
|
||||
value={name()}
|
||||
onInput={(event) => {
|
||||
setFormError(null)
|
||||
setName(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.port")}</div>
|
||||
<div class="settings-toggle-caption">127.0.0.1</div>
|
||||
</div>
|
||||
<input
|
||||
class="selector-input w-full max-w-xs"
|
||||
value={port()}
|
||||
onInput={(event) => {
|
||||
setFormError(null)
|
||||
setPort(event.currentTarget.value)
|
||||
}}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.protocol")}</div>
|
||||
<div class="settings-toggle-caption">{t("sidecars.form.protocol.help")}</div>
|
||||
</div>
|
||||
<select class="selector-input w-full max-w-xs" value={insecure() ? "http" : "https"} onChange={(event) => setInsecure(event.currentTarget.value === "http") }>
|
||||
<option value="https">{t("sidecars.form.protocol.https")}</option>
|
||||
<option value="http">{t("sidecars.form.protocol.http")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.prefixMode")}</div>
|
||||
<div class="settings-toggle-caption">{t("sidecars.form.prefixMode.help")}</div>
|
||||
</div>
|
||||
<select class="selector-input w-full max-w-xs" value={prefixMode()} onChange={(event) => setPrefixMode(event.currentTarget.value as "strip" | "preserve") }>
|
||||
<option value="strip">{t("sidecars.form.prefixMode.strip")}</option>
|
||||
<option value="preserve">{t("sidecars.form.prefixMode.preserve")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Show when={formError()}>
|
||||
<div class="text-sm text-red-500">{formError()}</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="selector-button selector-button-primary" disabled={creating()} onClick={() => void handleCreate()}>
|
||||
<Show when={creating()} fallback={<Plus class="w-4 h-4" />}>
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
</Show>
|
||||
<span>{t("sidecars.form.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("sidecars.settings.listTitle")}</h3>
|
||||
<p class="settings-card-subtitle">{t("sidecars.settings.listSubtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card-content">
|
||||
<Show when={actionError()}>
|
||||
<div class="text-sm text-red-500">{actionError()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!sidecarsLoading()} fallback={<div class="settings-card-message">{t("sidecars.picker.loading")}</div>}>
|
||||
<Show when={orderedSidecars().length > 0} fallback={<div class="settings-card-message">{t("sidecars.settings.empty")}</div>}>
|
||||
<For each={orderedSidecars()}>
|
||||
{(sidecar) => (
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{sidecar.name}</div>
|
||||
<div class="settings-toggle-caption">
|
||||
{t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||
</div>
|
||||
<div class="settings-toggle-caption">
|
||||
{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code> · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-secondary min-w-[4.5rem] text-right">{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||
<button type="button" class="selector-button selector-button-secondary" disabled={busyId() === sidecar.id} onClick={() => void handleDelete(sidecar.id)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { For, Show, createEffect, createMemo, type Component } from "solid-js"
|
||||
import { Globe, Square } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars"
|
||||
|
||||
interface SideCarPickerDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpenSidecar: (sidecarId: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export const SideCarPickerDialog: Component<SideCarPickerDialogProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
void ensureSidecarsLoaded()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<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-2xl p-6 flex flex-col gap-4 max-h-[80vh] overflow-hidden">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("sidecars.picker.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
{t("sidecars.picker.subtitle")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto flex flex-col gap-3">
|
||||
<Show when={!sidecarsLoading()} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.loading")}</div>}>
|
||||
<Show when={orderedSidecars().length > 0} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.empty")}</div>}>
|
||||
<For each={orderedSidecars()}>
|
||||
{(sidecar) => (
|
||||
<button
|
||||
type="button"
|
||||
class="panel-list-item panel-list-item-content text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={sidecar.status !== "running"}
|
||||
onClick={() => void props.onOpenSidecar(sidecar.id)}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 w-full">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="panel-empty-state-icon !w-10 !h-10">
|
||||
<Globe class="w-5 h-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-primary truncate">{sidecar.name}</div>
|
||||
<div class="text-xs text-muted">
|
||||
{t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||
</div>
|
||||
<div class="text-xs text-muted mt-1">{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-secondary flex items-center gap-2">
|
||||
<Square class="w-4 h-4" />
|
||||
<span>{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
{t("sidecars.picker.close")}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
197
packages/ui/src/components/sidecar-view.tsx
Normal file
197
packages/ui/src/components/sidecar-view.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
|
||||
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||
import type { SideCarTabRecord } from "../stores/sidecars"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface SideCarViewProps {
|
||||
tab: SideCarTabRecord
|
||||
}
|
||||
|
||||
export const SideCarView: Component<SideCarViewProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
|
||||
const [pathInput, setPathInput] = createSignal("/")
|
||||
let iframeRef: HTMLIFrameElement | undefined
|
||||
|
||||
const lockedBaseLabel = createMemo(() => {
|
||||
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
|
||||
if (props.tab.prefixMode === "preserve") {
|
||||
return `${hostLabel}${props.tab.proxyBasePath}`
|
||||
}
|
||||
return hostLabel
|
||||
})
|
||||
|
||||
const getEditablePathFromUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
const basePath = props.tab.proxyBasePath
|
||||
let pathname = parsed.pathname
|
||||
|
||||
if (basePath && pathname.startsWith(basePath)) {
|
||||
pathname = pathname.slice(basePath.length) || "/"
|
||||
}
|
||||
|
||||
if (!pathname.startsWith("/")) {
|
||||
pathname = `/${pathname}`
|
||||
}
|
||||
|
||||
return `${pathname}${parsed.search}${parsed.hash}`
|
||||
} catch {
|
||||
return "/"
|
||||
}
|
||||
}
|
||||
|
||||
const buildNormalizedTargetUrl = (rawInput: string): string => {
|
||||
const trimmed = rawInput.trim()
|
||||
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
|
||||
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
|
||||
|
||||
const safeSegments: string[] = []
|
||||
for (const segment of parsed.pathname.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (safeSegments.length > 0) {
|
||||
safeSegments.pop()
|
||||
}
|
||||
continue
|
||||
}
|
||||
safeSegments.push(segment)
|
||||
}
|
||||
|
||||
const normalizedPath = `/${safeSegments.join("/")}` || "/"
|
||||
const basePath = props.tab.proxyBasePath
|
||||
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
|
||||
const syncPathInputFromFrame = () => {
|
||||
try {
|
||||
const currentHref = iframeRef?.contentWindow?.location.href
|
||||
if (!currentHref) {
|
||||
return
|
||||
}
|
||||
setPathInput(getEditablePathFromUrl(currentHref))
|
||||
} catch {
|
||||
setPathInput(getEditablePathFromUrl(frameSrc()))
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
setFrameSrc(props.tab.shellUrl)
|
||||
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
|
||||
})
|
||||
|
||||
const handleBack = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
const frameWindow = iframeRef?.contentWindow
|
||||
if (!frameWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (frameWindow.history.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
frameWindow.focus()
|
||||
frameWindow.history.go(-1)
|
||||
} catch {
|
||||
// Ignore navigation errors from pages that do not expose history access.
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
try {
|
||||
iframeRef?.contentWindow?.location.reload()
|
||||
return
|
||||
} catch {
|
||||
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
|
||||
}
|
||||
|
||||
setFrameSrc("about:blank")
|
||||
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
|
||||
}
|
||||
|
||||
const handleGo = (event?: Event) => {
|
||||
event?.preventDefault()
|
||||
|
||||
const nextUrl = buildNormalizedTargetUrl(pathInput())
|
||||
setFrameSrc(nextUrl)
|
||||
setPathInput(getEditablePathFromUrl(nextUrl))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 px-3 py-2"
|
||||
style={{ "border-bottom": "1px solid var(--border-base)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="new-tab-button"
|
||||
onClick={handleBack}
|
||||
title={t("sidecars.back")}
|
||||
aria-label={t("sidecars.back")}
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="new-tab-button"
|
||||
onClick={handleRefresh}
|
||||
title={t("sidecars.refresh")}
|
||||
aria-label={t("sidecars.refresh")}
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
background: "var(--surface-secondary)",
|
||||
color: "var(--text-secondary)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
>
|
||||
{lockedBaseLabel()}
|
||||
</div>
|
||||
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
|
||||
style={{
|
||||
background: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
value={pathInput()}
|
||||
onInput={(event) => setPathInput(event.currentTarget.value)}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
aria-label={t("sidecars.path")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="new-tab-button"
|
||||
title={t("sidecars.go")}
|
||||
aria-label={t("sidecars.go")}
|
||||
>
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={frameSrc()}
|
||||
title={props.tab.name}
|
||||
class="min-h-0 flex-1 w-full border-0 bg-surface"
|
||||
referrerPolicy="same-origin"
|
||||
onLoad={syncPathInputFromFrame}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
SpeechCapabilitiesResponse,
|
||||
SpeechSynthesisResponse,
|
||||
SpeechTranscriptionResponse,
|
||||
SideCar,
|
||||
ServerMeta,
|
||||
RemoteServerProbeRequest,
|
||||
RemoteServerProbeResponse,
|
||||
@@ -193,6 +194,33 @@ export const serverApi = {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchSidecars(): Promise<{ sidecars: SideCar[] }> {
|
||||
return request<{ sidecars: SideCar[] }>("/api/sidecars")
|
||||
},
|
||||
createSidecar(payload: {
|
||||
kind: "port"
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: "strip" | "preserve"
|
||||
}): Promise<SideCar> {
|
||||
return request<SideCar>("/api/sidecars", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
updateSidecar(
|
||||
id: string,
|
||||
payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>,
|
||||
): Promise<SideCar> {
|
||||
return request<SideCar>(`/api/sidecars/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
deleteSidecar(id: string): Promise<void> {
|
||||
return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
fetchServerMeta(): Promise<ServerMeta> {
|
||||
return request<ServerMeta>("/api/meta")
|
||||
},
|
||||
@@ -438,4 +466,4 @@ function buildClientEventsUrl(identity: { clientId: string; connectionId: string
|
||||
return `${url.pathname}${url.search}`
|
||||
}
|
||||
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }
|
||||
|
||||
@@ -16,6 +16,7 @@ const log = getLogger("actions")
|
||||
interface UseAppLifecycleOptions {
|
||||
setEscapeInDebounce: (value: boolean) => void
|
||||
handleNewInstanceRequest: () => void
|
||||
handleCloseActiveTab: () => Promise<void>
|
||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||
handleNewSession: (instanceId: string) => Promise<void>
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||
@@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
|
||||
setupTabKeyboardShortcuts(
|
||||
options.handleNewInstanceRequest,
|
||||
options.handleCloseInstance,
|
||||
options.handleCloseActiveTab,
|
||||
options.handleNewSession,
|
||||
options.handleCloseSession,
|
||||
() => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
||||
import { createCommandRegistry, type Command } from "../commands"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import { activeInstanceId } from "../../stores/instances"
|
||||
import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs"
|
||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
@@ -41,6 +42,7 @@ export interface UseCommandsOptions {
|
||||
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
||||
handleNewInstanceRequest: () => void
|
||||
handleCloseActiveTab: () => Promise<void>
|
||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||
handleNewSession: (instanceId: string) => Promise<void>
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||
@@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
||||
shortcut: { key: "W", meta: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
await options.handleCloseInstance(instance.id)
|
||||
await options.handleCloseActiveTab()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
category: "Instance",
|
||||
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
||||
shortcut: { key: "]", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveInstanceId(ids[next])
|
||||
},
|
||||
action: () => selectNextAppTab(),
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
@@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
category: "Instance",
|
||||
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
||||
shortcut: { key: "[", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveInstanceId(ids[prev])
|
||||
},
|
||||
action: () => selectPreviousAppTab(),
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Open folder picker to create new instance",
|
||||
"commands.newInstance.keywords": "folder, project, workspace",
|
||||
|
||||
"commands.closeInstance.label": "Close Instance",
|
||||
"commands.closeInstance.description": "Stop current instance's server",
|
||||
"commands.closeInstance.keywords": "stop, quit, close",
|
||||
"commands.closeInstance.label": "Close Tab",
|
||||
"commands.closeInstance.description": "Close the current top-level tab",
|
||||
"commands.closeInstance.keywords": "stop, quit, close, tab",
|
||||
|
||||
"commands.nextInstance.label": "Next Instance",
|
||||
"commands.nextInstance.description": "Cycle to next instance tab",
|
||||
"commands.nextInstance.keywords": "switch, navigate",
|
||||
"commands.nextInstance.label": "Next Tab",
|
||||
"commands.nextInstance.description": "Cycle to the next top-level tab",
|
||||
"commands.nextInstance.keywords": "switch, navigate, tab",
|
||||
|
||||
"commands.previousInstance.label": "Previous Instance",
|
||||
"commands.previousInstance.description": "Cycle to previous instance tab",
|
||||
"commands.previousInstance.keywords": "switch, navigate",
|
||||
"commands.previousInstance.label": "Previous Tab",
|
||||
"commands.previousInstance.description": "Cycle to the previous top-level tab",
|
||||
"commands.previousInstance.keywords": "switch, navigate, tab",
|
||||
|
||||
"commands.newSession.label": "New Session",
|
||||
"commands.newSession.description": "Create a new parent session",
|
||||
|
||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -195,4 +195,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Saved",
|
||||
"settings.speech.save.unsaved": "Unsaved changes",
|
||||
"settings.speech.save.error": "Save failed",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
||||
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
||||
|
||||
"commands.closeInstance.label": "Cerrar instancia",
|
||||
"commands.closeInstance.description": "Detener el servidor de la instancia actual",
|
||||
"commands.closeInstance.keywords": "detener, salir, cerrar",
|
||||
"commands.closeInstance.label": "Cerrar pestaña",
|
||||
"commands.closeInstance.description": "Cerrar la pestaña superior actual",
|
||||
"commands.closeInstance.keywords": "detener, salir, cerrar, pestaña",
|
||||
|
||||
"commands.nextInstance.label": "Siguiente instancia",
|
||||
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia",
|
||||
"commands.nextInstance.keywords": "cambiar, navegar",
|
||||
"commands.nextInstance.label": "Siguiente pestaña",
|
||||
"commands.nextInstance.description": "Cambiar a la siguiente pestaña superior",
|
||||
"commands.nextInstance.keywords": "cambiar, navegar, pestaña",
|
||||
|
||||
"commands.previousInstance.label": "Instancia anterior",
|
||||
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior",
|
||||
"commands.previousInstance.keywords": "cambiar, navegar",
|
||||
"commands.previousInstance.label": "Pestaña anterior",
|
||||
"commands.previousInstance.description": "Cambiar a la pestaña superior anterior",
|
||||
"commands.previousInstance.keywords": "cambiar, navegar, pestaña",
|
||||
|
||||
"commands.newSession.label": "Nueva sesión",
|
||||
"commands.newSession.description": "Crear una nueva sesión principal",
|
||||
|
||||
@@ -2,23 +2,23 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.language.ariaLabel": "Idioma",
|
||||
|
||||
"folderSelection.logoAlt": "Logo de CodeNomad",
|
||||
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA",
|
||||
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI",
|
||||
|
||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub",
|
||||
"folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad",
|
||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||
|
||||
"folderSelection.empty.title": "No hay carpetas recientes",
|
||||
"folderSelection.empty.description": "Explora una carpeta para comenzar",
|
||||
"folderSelection.empty.description": "Busca una carpeta para comenzar",
|
||||
|
||||
"folderSelection.recent.title": "Carpetas recientes",
|
||||
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
||||
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
|
||||
"folderSelection.recent.remove": "Quitar de recientes",
|
||||
"folderSelection.recent.remove": "Eliminar de recientes",
|
||||
|
||||
"folderSelection.browse.title": "Explorar carpetas",
|
||||
"folderSelection.browse.title": "Buscar carpeta",
|
||||
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
||||
"folderSelection.browse.button": "Explorar carpetas",
|
||||
"folderSelection.browse.button": "Buscar carpetas",
|
||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
|
||||
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
|
||||
@@ -29,11 +29,11 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.hints.navigate": "Navegar",
|
||||
"folderSelection.hints.select": "Seleccionar",
|
||||
"folderSelection.hints.remove": "Quitar",
|
||||
"folderSelection.hints.browse": "Explorar",
|
||||
"folderSelection.hints.remove": "Eliminar",
|
||||
"folderSelection.hints.browse": "Buscar",
|
||||
|
||||
"folderSelection.loading.title": "Iniciando instancia...",
|
||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
||||
"folderSelection.loading.subtitle": "Espera mientras preparamos tu espacio de trabajo.",
|
||||
|
||||
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Guardado",
|
||||
"settings.speech.save.unsaved": "Cambios sin guardar",
|
||||
"settings.speech.save.error": "Error al guardar",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
||||
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
||||
|
||||
"commands.closeInstance.label": "Fermer l'instance",
|
||||
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle",
|
||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer",
|
||||
"commands.closeInstance.label": "Fermer l'onglet",
|
||||
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
|
||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
|
||||
|
||||
"commands.nextInstance.label": "Instance suivante",
|
||||
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant",
|
||||
"commands.nextInstance.keywords": "changer, naviguer, suivant",
|
||||
"commands.nextInstance.label": "Onglet suivant",
|
||||
"commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
|
||||
"commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
|
||||
|
||||
"commands.previousInstance.label": "Instance précédente",
|
||||
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent",
|
||||
"commands.previousInstance.keywords": "changer, naviguer, précédent",
|
||||
"commands.previousInstance.label": "Onglet précédent",
|
||||
"commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
|
||||
"commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
|
||||
|
||||
"commands.newSession.label": "Nouvelle session",
|
||||
"commands.newSession.description": "Créer une nouvelle session parente",
|
||||
|
||||
@@ -5,7 +5,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
||||
|
||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Stars GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad",
|
||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||
|
||||
"folderSelection.empty.title": "Aucun dossier récent",
|
||||
@@ -16,13 +16,13 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
||||
"folderSelection.recent.remove": "Retirer des récents",
|
||||
|
||||
"folderSelection.browse.title": "Parcourir les dossiers",
|
||||
"folderSelection.browse.title": "Parcourir un dossier",
|
||||
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
||||
"folderSelection.browse.button": "Parcourir les dossiers",
|
||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||
"folderSelection.actions.title": "Ouvrir un dossier ou connecter un serveur",
|
||||
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
|
||||
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
|
||||
"folderSelection.actions.connectButton": "Connecter un serveur CodeNomad",
|
||||
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
|
||||
|
||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Enregistré",
|
||||
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
||||
"settings.speech.save.error": "Échec de l'enregistrement",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||
|
||||
"commands.closeInstance.label": "סגור מופע",
|
||||
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
|
||||
"commands.closeInstance.keywords": "עצור, סגור",
|
||||
"commands.closeInstance.label": "סגור לשונית",
|
||||
"commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
|
||||
"commands.closeInstance.keywords": "עצור, סגור, לשונית",
|
||||
|
||||
"commands.nextInstance.label": "מופע הבא",
|
||||
"commands.nextInstance.description": "עבור למופע הבא",
|
||||
"commands.nextInstance.keywords": "החלף, נווט",
|
||||
"commands.nextInstance.label": "הלשונית הבאה",
|
||||
"commands.nextInstance.description": "עבור ללשונית העליונה הבאה",
|
||||
"commands.nextInstance.keywords": "החלף, נווט, לשונית",
|
||||
|
||||
"commands.previousInstance.label": "מופע קודם",
|
||||
"commands.previousInstance.description": "עבור למופע הקודם",
|
||||
"commands.previousInstance.keywords": "החלף, נווט",
|
||||
"commands.previousInstance.label": "הלשונית הקודמת",
|
||||
"commands.previousInstance.description": "עבור ללשונית העליונה הקודמת",
|
||||
"commands.previousInstance.keywords": "החלף, נווט, לשונית",
|
||||
|
||||
"commands.newSession.label": "סשן חדש",
|
||||
"commands.newSession.description": "צור סשן הורה חדש",
|
||||
|
||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -193,4 +193,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "נשמר",
|
||||
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
|
||||
"settings.speech.save.error": "השמירה נכשלה",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成",
|
||||
"commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace",
|
||||
|
||||
"commands.closeInstance.label": "インスタンスを閉じる",
|
||||
"commands.closeInstance.description": "現在のインスタンスのサーバーを停止",
|
||||
"commands.closeInstance.keywords": "停止, 終了, 閉じる, stop, quit, close",
|
||||
"commands.closeInstance.label": "タブを閉じる",
|
||||
"commands.closeInstance.description": "現在のトップレベルタブを閉じる",
|
||||
"commands.closeInstance.keywords": "閉じる, タブ, stop, quit, close",
|
||||
|
||||
"commands.nextInstance.label": "次のインスタンス",
|
||||
"commands.nextInstance.description": "次のインスタンスタブへ切り替え",
|
||||
"commands.nextInstance.keywords": "切り替え, 移動, switch, navigate",
|
||||
"commands.nextInstance.label": "次のタブ",
|
||||
"commands.nextInstance.description": "次のトップレベルタブへ切り替え",
|
||||
"commands.nextInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
|
||||
|
||||
"commands.previousInstance.label": "前のインスタンス",
|
||||
"commands.previousInstance.description": "前のインスタンスタブへ切り替え",
|
||||
"commands.previousInstance.keywords": "切り替え, 移動, switch, navigate",
|
||||
"commands.previousInstance.label": "前のタブ",
|
||||
"commands.previousInstance.description": "前のトップレベルタブへ切り替え",
|
||||
"commands.previousInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
|
||||
|
||||
"commands.newSession.label": "新しいセッション",
|
||||
"commands.newSession.description": "新しい親セッションを作成",
|
||||
|
||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "接続中...",
|
||||
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
||||
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "保存済み",
|
||||
"settings.speech.save.unsaved": "未保存の変更",
|
||||
"settings.speech.save.error": "保存に失敗しました",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра",
|
||||
"commands.newInstance.keywords": "папка, проект, рабочее пространство",
|
||||
|
||||
"commands.closeInstance.label": "Закрыть экземпляр",
|
||||
"commands.closeInstance.description": "Остановить сервер текущего экземпляра",
|
||||
"commands.closeInstance.keywords": "остановить, выйти, закрыть",
|
||||
"commands.closeInstance.label": "Закрыть вкладку",
|
||||
"commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку",
|
||||
"commands.closeInstance.keywords": "остановить, выйти, закрыть, вкладка",
|
||||
|
||||
"commands.nextInstance.label": "Следующий экземпляр",
|
||||
"commands.nextInstance.description": "Переключиться на следующую вкладку экземпляра",
|
||||
"commands.nextInstance.keywords": "переключить, навигация",
|
||||
"commands.nextInstance.label": "Следующая вкладка",
|
||||
"commands.nextInstance.description": "Переключиться на следующую верхнеуровневую вкладку",
|
||||
"commands.nextInstance.keywords": "переключить, навигация, вкладка",
|
||||
|
||||
"commands.previousInstance.label": "Предыдущий экземпляр",
|
||||
"commands.previousInstance.description": "Переключиться на предыдущую вкладку экземпляра",
|
||||
"commands.previousInstance.keywords": "переключить, навигация",
|
||||
"commands.previousInstance.label": "Предыдущая вкладка",
|
||||
"commands.previousInstance.description": "Переключиться на предыдущую верхнеуровневую вкладку",
|
||||
"commands.previousInstance.keywords": "переключить, навигация, вкладка",
|
||||
|
||||
"commands.newSession.label": "Новая сессия",
|
||||
"commands.newSession.description": "Создать новую родительскую сессию",
|
||||
|
||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Подключение...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Сохранено",
|
||||
"settings.speech.save.unsaved": "Есть несохранённые изменения",
|
||||
"settings.speech.save.error": "Не удалось сохранить",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "打开文件夹选择器以创建新实例",
|
||||
"commands.newInstance.keywords": "folder, project, workspace, 文件夹, 项目, 工作区",
|
||||
|
||||
"commands.closeInstance.label": "关闭实例",
|
||||
"commands.closeInstance.description": "停止当前实例的服务器",
|
||||
"commands.closeInstance.keywords": "stop, quit, close, 停止, 退出, 关闭",
|
||||
"commands.closeInstance.label": "关闭标签页",
|
||||
"commands.closeInstance.description": "关闭当前顶层标签页",
|
||||
"commands.closeInstance.keywords": "stop, quit, close, 停止, 退出, 关闭, 标签",
|
||||
|
||||
"commands.nextInstance.label": "下一个实例",
|
||||
"commands.nextInstance.description": "切换到下一个实例标签页",
|
||||
"commands.nextInstance.keywords": "switch, navigate, 切换, 导航",
|
||||
"commands.nextInstance.label": "下一个标签页",
|
||||
"commands.nextInstance.description": "切换到下一个顶层标签页",
|
||||
"commands.nextInstance.keywords": "switch, navigate, 切换, 导航, 标签",
|
||||
|
||||
"commands.previousInstance.label": "上一个实例",
|
||||
"commands.previousInstance.description": "切换到上一个实例标签页",
|
||||
"commands.previousInstance.keywords": "switch, navigate, 切换, 导航",
|
||||
"commands.previousInstance.label": "上一个标签页",
|
||||
"commands.previousInstance.description": "切换到上一个顶层标签页",
|
||||
"commands.previousInstance.keywords": "switch, navigate, 切换, 导航, 标签",
|
||||
|
||||
"commands.newSession.label": "新建会话",
|
||||
"commands.newSession.description": "创建新的父会话",
|
||||
|
||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "连接中...",
|
||||
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
||||
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "已保存",
|
||||
"settings.speech.save.unsaved": "有未保存的更改",
|
||||
"settings.speech.save.error": "保存失败",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
|
||||
import { activeInstanceId } from "../stores/instances"
|
||||
import { selectAppTabByIndex } from "../stores/app-tabs"
|
||||
import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
|
||||
import { keyboardRegistry } from "./keyboard-registry"
|
||||
import { isMac } from "./keyboard-utils"
|
||||
|
||||
export function setupTabKeyboardShortcuts(
|
||||
handleNewInstance: () => void,
|
||||
handleCloseInstance: (instanceId: string) => void,
|
||||
handleCloseActiveTab: () => Promise<void>,
|
||||
handleNewSession: (instanceId: string) => void,
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => void,
|
||||
handleCommandPalette: () => void,
|
||||
@@ -35,11 +36,7 @@ export function setupTabKeyboardShortcuts(
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key >= "1" && e.key <= "9") {
|
||||
e.preventDefault()
|
||||
const index = parseInt(e.key) - 1
|
||||
const instanceIds = Array.from(instances().keys())
|
||||
if (instanceIds[index]) {
|
||||
setActiveInstanceId(instanceIds[index])
|
||||
}
|
||||
selectAppTabByIndex(parseInt(e.key) - 1)
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key >= "1" && e.key <= "9") {
|
||||
@@ -67,10 +64,7 @@ export function setupTabKeyboardShortcuts(
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "w") {
|
||||
e.preventDefault()
|
||||
const instanceId = activeInstanceId()
|
||||
if (instanceId) {
|
||||
handleCloseInstance(instanceId)
|
||||
}
|
||||
void handleCloseActiveTab()
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "w") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import { activeInstanceId } from "../../stores/instances"
|
||||
import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs"
|
||||
import { activeSessionId, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||
|
||||
export function registerNavigationShortcuts() {
|
||||
@@ -11,14 +12,8 @@ export function registerNavigationShortcuts() {
|
||||
id: "instance-prev",
|
||||
key: "[",
|
||||
modifiers: { ctrl: !isMac(), meta: isMac() },
|
||||
handler: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveInstanceId(ids[prev])
|
||||
},
|
||||
description: "previous instance",
|
||||
handler: () => selectPreviousAppTab(),
|
||||
description: "previous tab",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
@@ -26,14 +21,8 @@ export function registerNavigationShortcuts() {
|
||||
id: "instance-next",
|
||||
key: "]",
|
||||
modifiers: { ctrl: !isMac(), meta: isMac() },
|
||||
handler: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveInstanceId(ids[next])
|
||||
},
|
||||
description: "next instance",
|
||||
handler: () => selectNextAppTab(),
|
||||
description: "next tab",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
|
||||
172
packages/ui/src/stores/app-tabs.ts
Normal file
172
packages/ui/src/stores/app-tabs.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { activeInstanceId, instances, setActiveInstanceId } from "./instances"
|
||||
import { activeSidecarToken, setActiveSidecarToken, sidecarTabs, type SideCarTabRecord } from "./sidecars"
|
||||
|
||||
export interface InstanceAppTab {
|
||||
id: string
|
||||
kind: "instance"
|
||||
instance: Instance
|
||||
}
|
||||
|
||||
export interface SideCarAppTab {
|
||||
id: string
|
||||
kind: "sidecar"
|
||||
sidecarTab: SideCarTabRecord
|
||||
}
|
||||
|
||||
export type AppTabRecord = InstanceAppTab | SideCarAppTab
|
||||
|
||||
function getInstanceAppTabId(instanceId: string): string {
|
||||
return `instance:${instanceId}`
|
||||
}
|
||||
|
||||
function getSidecarAppTabId(token: string): string {
|
||||
return `sidecar:${token}`
|
||||
}
|
||||
|
||||
function getAdjacentAppTabId(tabId: string): string | null {
|
||||
const tabs = appTabs()
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index < 0) return activeAppTabId()
|
||||
return tabs[index - 1]?.id ?? tabs[index + 1]?.id ?? null
|
||||
}
|
||||
|
||||
function getPreferredTabId(): string | null {
|
||||
const sidecarToken = activeSidecarToken()
|
||||
if (sidecarToken) {
|
||||
return getSidecarAppTabId(sidecarToken)
|
||||
}
|
||||
|
||||
const instanceId = activeInstanceId()
|
||||
if (instanceId) {
|
||||
return getInstanceAppTabId(instanceId)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const [activeAppTabId, setActiveAppTabId] = createSignal<string | null>(null)
|
||||
const [tabOrder, setTabOrder] = createSignal<string[]>([])
|
||||
|
||||
function rememberTabOrder(tabId: string) {
|
||||
setTabOrder((prev) => (prev.includes(tabId) ? prev : [...prev, tabId]))
|
||||
}
|
||||
|
||||
const appTabs = createMemo<AppTabRecord[]>(() => {
|
||||
const currentTabs = [
|
||||
...Array.from(instances().values()).map((instance) => ({
|
||||
id: getInstanceAppTabId(instance.id),
|
||||
kind: "instance" as const,
|
||||
instance,
|
||||
})),
|
||||
...sidecarTabs().map((sidecarTab) => ({
|
||||
id: getSidecarAppTabId(sidecarTab.token),
|
||||
kind: "sidecar" as const,
|
||||
sidecarTab,
|
||||
})),
|
||||
]
|
||||
|
||||
const tabsById = new Map(currentTabs.map((tab) => [tab.id, tab]))
|
||||
const orderedIds = tabOrder().filter((tabId) => tabsById.has(tabId))
|
||||
const missingIds = currentTabs.map((tab) => tab.id).filter((tabId) => !orderedIds.includes(tabId))
|
||||
|
||||
return [...orderedIds, ...missingIds].map((tabId) => tabsById.get(tabId)!).filter(Boolean)
|
||||
})
|
||||
|
||||
const activeAppTab = createMemo(() => appTabs().find((tab) => tab.id === activeAppTabId()) ?? null)
|
||||
|
||||
function getAppTabById(tabId: string | null): AppTabRecord | null {
|
||||
if (!tabId) return null
|
||||
return appTabs().find((tab) => tab.id === tabId) ?? null
|
||||
}
|
||||
|
||||
function selectAppTab(tabId: string | null) {
|
||||
if (!tabId) {
|
||||
setActiveAppTabId(null)
|
||||
setActiveSidecarToken(null)
|
||||
return
|
||||
}
|
||||
|
||||
const tab = appTabs().find((entry) => entry.id === tabId)
|
||||
if (!tab) return
|
||||
|
||||
rememberTabOrder(tab.id)
|
||||
setActiveAppTabId(tab.id)
|
||||
|
||||
if (tab.kind === "instance") {
|
||||
setActiveSidecarToken(null)
|
||||
setActiveInstanceId(tab.instance.id)
|
||||
return
|
||||
}
|
||||
|
||||
setActiveInstanceId(null)
|
||||
setActiveSidecarToken(tab.sidecarTab.token)
|
||||
}
|
||||
|
||||
function selectInstanceTab(instanceId: string) {
|
||||
selectAppTab(getInstanceAppTabId(instanceId))
|
||||
}
|
||||
|
||||
function selectSidecarTab(token: string) {
|
||||
selectAppTab(getSidecarAppTabId(token))
|
||||
}
|
||||
|
||||
function selectNextAppTab() {
|
||||
const tabs = appTabs()
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
const current = tabs.findIndex((tab) => tab.id === activeAppTabId())
|
||||
const nextIndex = current < 0 ? 0 : (current + 1) % tabs.length
|
||||
const nextTab = tabs[nextIndex]
|
||||
if (nextTab) selectAppTab(nextTab.id)
|
||||
}
|
||||
|
||||
function selectPreviousAppTab() {
|
||||
const tabs = appTabs()
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
const current = tabs.findIndex((tab) => tab.id === activeAppTabId())
|
||||
const previousIndex = current <= 0 ? tabs.length - 1 : current - 1
|
||||
const previousTab = tabs[previousIndex]
|
||||
if (previousTab) selectAppTab(previousTab.id)
|
||||
}
|
||||
|
||||
function selectAppTabByIndex(index: number) {
|
||||
const tab = appTabs()[index]
|
||||
if (tab) selectAppTab(tab.id)
|
||||
}
|
||||
|
||||
function ensureActiveAppTab(preferredTabId?: string | null) {
|
||||
const tabs = appTabs()
|
||||
const current = activeAppTabId()
|
||||
|
||||
if (current && tabs.some((tab) => tab.id === current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidateId = preferredTabId ?? getPreferredTabId()
|
||||
if (candidateId && tabs.some((tab) => tab.id === candidateId)) {
|
||||
selectAppTab(candidateId)
|
||||
return
|
||||
}
|
||||
|
||||
selectAppTab(tabs[0]?.id ?? null)
|
||||
}
|
||||
|
||||
export {
|
||||
activeAppTabId,
|
||||
activeAppTab,
|
||||
appTabs,
|
||||
ensureActiveAppTab,
|
||||
getAdjacentAppTabId,
|
||||
getAppTabById,
|
||||
getInstanceAppTabId,
|
||||
getSidecarAppTabId,
|
||||
selectAppTab,
|
||||
selectAppTabByIndex,
|
||||
selectInstanceTab,
|
||||
selectNextAppTab,
|
||||
selectPreviousAppTab,
|
||||
selectSidecarTab,
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||
import { showWorkspaceLaunchError } from "./launch-errors"
|
||||
import { activeSidecarToken } from "./sidecars"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -109,6 +110,8 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
||||
}
|
||||
|
||||
function ensureActiveInstanceSelected(): void {
|
||||
if (activeSidecarToken()) return
|
||||
|
||||
const current = activeInstanceId()
|
||||
const instanceMap = instances()
|
||||
if (current && instanceMap.has(current)) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode"
|
||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" | "sidecars"
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = createSignal(false)
|
||||
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
||||
|
||||
149
packages/ui/src/stores/sidecars.ts
Normal file
149
packages/ui/src/stores/sidecars.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import type { SideCar } from "../../../server/src/api-types"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
export interface SideCarTabRecord {
|
||||
token: string
|
||||
sidecarId: string
|
||||
name: string
|
||||
port?: number
|
||||
prefixMode: SideCar["prefixMode"]
|
||||
proxyBasePath: string
|
||||
shellUrl: string
|
||||
}
|
||||
|
||||
function buildSidecarShellUrl(sidecarId: string): string {
|
||||
return `/sidecars/${encodeURIComponent(sidecarId)}/`
|
||||
}
|
||||
|
||||
const [sidecars, setSidecars] = createSignal<Map<string, SideCar>>(new Map())
|
||||
const [sidecarTabs, setSidecarTabs] = createSignal<SideCarTabRecord[]>([])
|
||||
const [activeSidecarToken, setActiveSidecarToken] = createSignal<string | null>(null)
|
||||
const [sidecarsLoading, setSidecarsLoading] = createSignal(false)
|
||||
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
async function ensureSidecarsLoaded() {
|
||||
if (loadPromise) return loadPromise
|
||||
setSidecarsLoading(true)
|
||||
loadPromise = serverApi.fetchSidecars()
|
||||
.then((result) => {
|
||||
setSidecars(new Map(result.sidecars.map((sidecar) => [sidecar.id, sidecar])))
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to load SideCars", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setSidecarsLoading(false)
|
||||
loadPromise = null
|
||||
})
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
function upsertSidecar(sidecar: SideCar) {
|
||||
setSidecars((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(sidecar.id, sidecar)
|
||||
return next
|
||||
})
|
||||
|
||||
setSidecarTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.sidecarId === sidecar.id
|
||||
? {
|
||||
...tab,
|
||||
name: sidecar.name,
|
||||
port: sidecar.port,
|
||||
prefixMode: sidecar.prefixMode,
|
||||
proxyBasePath: buildSidecarShellUrl(sidecar.id).replace(/\/$/, ""),
|
||||
shellUrl: buildSidecarShellUrl(sidecar.id),
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function removeSidecar(sidecarId: string) {
|
||||
setSidecars((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(sidecarId)
|
||||
return next
|
||||
})
|
||||
|
||||
setSidecarTabs((prev) => {
|
||||
const next = prev.filter((tab) => tab.sidecarId !== sidecarId)
|
||||
if (!next.some((tab) => tab.token === activeSidecarToken())) {
|
||||
setActiveSidecarToken(next[0]?.token ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
serverEvents.on("sidecar.updated", (event) => {
|
||||
if (event.type !== "sidecar.updated") return
|
||||
upsertSidecar(event.sidecar)
|
||||
})
|
||||
|
||||
serverEvents.on("sidecar.removed", (event) => {
|
||||
if (event.type !== "sidecar.removed") return
|
||||
removeSidecar(event.sidecarId)
|
||||
})
|
||||
|
||||
async function openSidecarTab(sidecarId: string) {
|
||||
await ensureSidecarsLoaded()
|
||||
|
||||
const sidecar = sidecars().get(sidecarId)
|
||||
if (!sidecar) {
|
||||
throw new Error(tGlobal("sidecars.open.notFound"))
|
||||
}
|
||||
if (sidecar.status !== "running") {
|
||||
throw new Error(tGlobal("sidecars.open.notRunning"))
|
||||
}
|
||||
|
||||
const token = `${sidecarId}:${Date.now().toString(36)}`
|
||||
const nextTab: SideCarTabRecord = {
|
||||
token,
|
||||
sidecarId,
|
||||
name: sidecar.name,
|
||||
port: sidecar.port,
|
||||
prefixMode: sidecar.prefixMode,
|
||||
proxyBasePath: buildSidecarShellUrl(sidecarId).replace(/\/$/, ""),
|
||||
shellUrl: buildSidecarShellUrl(sidecarId),
|
||||
}
|
||||
|
||||
setSidecarTabs((prev) => [...prev, nextTab])
|
||||
setActiveSidecarToken(nextTab.token)
|
||||
return nextTab
|
||||
}
|
||||
|
||||
function closeSidecarTab(token: string) {
|
||||
setSidecarTabs((prev) => {
|
||||
const index = prev.findIndex((tab) => tab.token === token)
|
||||
if (index < 0) return prev
|
||||
const next = prev.filter((tab) => tab.token !== token)
|
||||
if (activeSidecarToken() === token) {
|
||||
const fallback = next[index - 1] ?? next[index] ?? null
|
||||
setActiveSidecarToken(fallback?.token ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const activeSidecarTab = createMemo(() => sidecarTabs().find((tab) => tab.token === activeSidecarToken()) ?? null)
|
||||
|
||||
export {
|
||||
sidecars,
|
||||
sidecarTabs,
|
||||
activeSidecarToken,
|
||||
activeSidecarTab,
|
||||
sidecarsLoading,
|
||||
setActiveSidecarToken,
|
||||
ensureSidecarsLoaded,
|
||||
openSidecarTab,
|
||||
closeSidecarTab,
|
||||
}
|
||||
@@ -154,6 +154,31 @@
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-pill {
|
||||
@apply inline-flex items-center gap-1 px-3 py-2 rounded-t-md max-w-[220px] text-sm;
|
||||
background-color: var(--tab-inactive-bg);
|
||||
color: var(--tab-inactive-text);
|
||||
border-bottom: 2px solid var(--tab-active-bg);
|
||||
}
|
||||
|
||||
.tab-pill-active {
|
||||
background-color: var(--tab-active-bg);
|
||||
color: var(--tab-active-text);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tab-pill-button {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.tab-pill-close {
|
||||
@apply inline-flex items-center justify-center rounded w-5 h-5 text-xs;
|
||||
}
|
||||
|
||||
.tab-pill-close:hover {
|
||||
background-color: var(--new-tab-hover-bg);
|
||||
}
|
||||
|
||||
/* Session tabs */
|
||||
.session-tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm;
|
||||
|
||||
Reference in New Issue
Block a user