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:
Shantur Rathore
2026-04-02 23:00:17 +01:00
committed by GitHub
parent 19a4c3df16
commit d0a0325d7e
47 changed files with 2139 additions and 218 deletions

View File

@@ -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 />

View File

@@ -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 */}

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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":

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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 }

View File

@@ -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,
() => {

View File

@@ -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({

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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": "צור סשן הורה חדש",

View File

@@ -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

View File

@@ -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

View File

@@ -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": "新しい親セッションを作成",

View File

@@ -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

View File

@@ -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

View File

@@ -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": "Создать новую родительскую сессию",

View File

@@ -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

View File

@@ -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

View File

@@ -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": "创建新的父会话",

View File

@@ -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

View File

@@ -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

View File

@@ -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") {

View File

@@ -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",
})

View 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,
}

View File

@@ -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

View File

@@ -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")

View 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,
}

View File

@@ -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;