Compare commits
5 Commits
v0.13.3-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403a3ff189 | ||
|
|
7996e514c4 | ||
|
|
141be2cde0 | ||
|
|
259d457209 | ||
|
|
d0a0325d7e |
@@ -1,7 +1,7 @@
|
|||||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, mkdirSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
|||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
function configureDevStoragePaths() {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = "CodeNomad"
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.setName(appName)
|
||||||
|
|
||||||
|
const userDataPath = join(app.getPath("appData"), appName)
|
||||||
|
const sessionDataPath = join(userDataPath, "session-data")
|
||||||
|
|
||||||
|
mkdirSync(userDataPath, { recursive: true })
|
||||||
|
mkdirSync(sessionDataPath, { recursive: true })
|
||||||
|
|
||||||
|
app.setPath("userData", userDataPath)
|
||||||
|
app.setPath("sessionData", sessionDataPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to configure dev storage paths", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureDevStoragePaths()
|
||||||
|
|
||||||
const cliManager = new CliProcessManager()
|
const cliManager = new CliProcessManager()
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
|
|||||||
@@ -170,6 +170,24 @@ export interface InstanceStreamEvent {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SideCarKind = "port"
|
||||||
|
|
||||||
|
export type SideCarPrefixMode = "strip" | "preserve"
|
||||||
|
|
||||||
|
export type SideCarStatus = "running" | "stopped"
|
||||||
|
|
||||||
|
export interface SideCar {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
status: SideCarStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BinaryRecord {
|
export interface BinaryRecord {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -276,6 +294,8 @@ export type WorkspaceEventType =
|
|||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
|
| "sidecar.updated"
|
||||||
|
| "sidecar.removed"
|
||||||
| "storage.configChanged"
|
| "storage.configChanged"
|
||||||
| "storage.stateChanged"
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
@@ -288,6 +308,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
|
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||||
|
| { type: "sidecar.removed"; sidecarId: string }
|
||||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
|
|||||||
@@ -104,13 +104,18 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||||
|
return this.getSessionFromHeaders(request.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
// When auth is disabled, treat all requests as authenticated.
|
// When auth is disabled, treat all requests as authenticated.
|
||||||
// We still return a stable username so callers can display it.
|
// We still return a stable username so callers can display it.
|
||||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookies = parseCookies(request.headers.cookie)
|
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
|
||||||
|
const cookies = parseCookies(cookieHeader)
|
||||||
const sessionId = cookies[this.cookieName]
|
const sessionId = cookies[this.cookieName]
|
||||||
const session = this.sessionManager.getSession(sessionId)
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
|
this.on("sidecar.updated", handler)
|
||||||
|
this.on("sidecar.removed", handler)
|
||||||
this.on("storage.configChanged", handler)
|
this.on("storage.configChanged", handler)
|
||||||
this.on("storage.stateChanged", handler)
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
|
this.off("sidecar.updated", handler)
|
||||||
|
this.off("sidecar.removed", handler)
|
||||||
this.off("storage.configChanged", handler)
|
this.off("storage.configChanged", handler)
|
||||||
this.off("storage.stateChanged", handler)
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { resolveHttpsOptions } from "./server/tls"
|
|||||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
|
import { SideCarManager } from "./sidecars/manager"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -315,6 +316,11 @@ async function main() {
|
|||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||||
|
const sidecarManager = new SideCarManager({
|
||||||
|
settings,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "sidecars" }),
|
||||||
|
})
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -400,6 +406,7 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
@@ -421,6 +428,7 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
@@ -520,6 +528,12 @@ async function main() {
|
|||||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sidecarManager.shutdown()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import cors from "@fastify/cors"
|
|||||||
import fastifyStatic from "@fastify/static"
|
import fastifyStatic from "@fastify/static"
|
||||||
import replyFrom from "@fastify/reply-from"
|
import replyFrom from "@fastify/reply-from"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { connect as connectTcp, type Socket } from "net"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { connect as connectTls, type TLSSocket } from "tls"
|
||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
@@ -23,6 +25,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
|||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
|
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
@@ -33,6 +36,7 @@ import type { SpeechService } from "../speech/service"
|
|||||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||||
import { PluginChannelManager } from "../plugins/channel"
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||||
|
import type { SideCarManager } from "../sidecars/manager"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -48,6 +52,7 @@ interface HttpServerDeps {
|
|||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
speechService: SpeechService
|
speechService: SpeechService
|
||||||
|
sidecarManager: SideCarManager
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
@@ -204,7 +209,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
const session = deps.authManager.getSessionFromRequest(request)
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
|
||||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||||
if (requiresAuthForApi && !session) {
|
if (requiresAuthForApi && !session) {
|
||||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
@@ -273,6 +278,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
})
|
})
|
||||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||||
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||||
|
setupSideCarWebSocketProxy(app, {
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: proxyLogger,
|
||||||
|
})
|
||||||
registerPluginRoutes(app, {
|
registerPluginRoutes(app, {
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
@@ -355,6 +367,68 @@ interface InstanceProxyDeps {
|
|||||||
logger: Logger
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SideCarProxyDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||||
|
authManager: AuthManager
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||||
|
const proxyBaseHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyWildcardHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: request.params["*"] ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/sidecars/:id", proxyBaseHandler)
|
||||||
|
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||||
|
app.server.on("upgrade", (request, socket, head) => {
|
||||||
|
const rawUrl = request.url ?? "/"
|
||||||
|
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||||
|
if (!parsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void proxySideCarWebSocketUpgrade({
|
||||||
|
request,
|
||||||
|
socket: socket as Socket,
|
||||||
|
head,
|
||||||
|
sidecarId: parsed.sidecarId,
|
||||||
|
incomingPath: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||||
app.register(async (instance) => {
|
app.register(async (instance) => {
|
||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
@@ -839,3 +913,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function proxySideCarRequest(args: {
|
||||||
|
request: FastifyRequest
|
||||||
|
reply: FastifyReply
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
pathSuffix?: string
|
||||||
|
}) {
|
||||||
|
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||||
|
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
args.reply.code(404).send({ error: "SideCar not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||||
|
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||||
|
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||||
|
const pathSuffix = args.pathSuffix ?? ""
|
||||||
|
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||||
|
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||||
|
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetUrl = `${targetOrigin}${targetPath}`
|
||||||
|
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||||
|
|
||||||
|
await args.reply.from(targetUrl, {
|
||||||
|
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||||
|
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||||
|
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||||
|
onError: (reply, { error }) => {
|
||||||
|
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl, "http://localhost")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||||
|
pathname: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxySideCarWebSocketUpgrade(args: {
|
||||||
|
request: import("http").IncomingMessage
|
||||||
|
socket: Socket
|
||||||
|
head: Buffer
|
||||||
|
sidecarId: string
|
||||||
|
incomingPath: string
|
||||||
|
search: string
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
authManager: AuthManager
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||||
|
|
||||||
|
if (!isWebSocketUpgradeRequest(request)) {
|
||||||
|
rejectUpgrade(socket, 400, "Bad Request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromHeaders(request.headers)
|
||||||
|
if (!session) {
|
||||||
|
rejectUpgrade(socket, 401, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidecar = await sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
rejectUpgrade(socket, 404, "Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||||
|
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||||
|
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||||
|
|
||||||
|
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||||
|
|
||||||
|
const closeBoth = () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream.once("error", (error) => {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||||
|
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("error", (error) => {
|
||||||
|
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once(readyEvent, () => {
|
||||||
|
try {
|
||||||
|
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||||
|
if (head.length > 0) {
|
||||||
|
upstream.write(head)
|
||||||
|
}
|
||||||
|
upstream.pipe(socket)
|
||||||
|
socket.pipe(upstream)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||||
|
closeBoth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once("close", () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("close", () => {
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||||
|
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||||
|
if (targetUrl.protocol === "https:") {
|
||||||
|
return {
|
||||||
|
socket: connectTls({
|
||||||
|
host: targetUrl.hostname,
|
||||||
|
port,
|
||||||
|
servername: targetUrl.hostname,
|
||||||
|
}),
|
||||||
|
readyEvent: "secureConnect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
socket: connectTcp(port, targetUrl.hostname),
|
||||||
|
readyEvent: "connect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||||
|
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||||
|
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||||
|
const headerLines: string[] = []
|
||||||
|
const rawHeaders = request.rawHeaders ?? []
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
|
||||||
|
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||||
|
const key = rawHeaders[index]
|
||||||
|
const value = rawHeaders[index + 1]
|
||||||
|
if (!key || value === undefined) continue
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (blockedHeaders.has(lower)) continue
|
||||||
|
if (lower === "origin") {
|
||||||
|
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headerLines.push(`${key}: ${value}\r\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||||
|
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||||
|
headerLines.push("\r\n")
|
||||||
|
|
||||||
|
return requestLine + headerLines.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||||
|
const upgrade = request.headers.upgrade
|
||||||
|
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const connection = request.headers.connection
|
||||||
|
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||||
|
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSideCarResponseHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
sidecarId: string,
|
||||||
|
targetOrigin: string,
|
||||||
|
prefixMode: "strip" | "preserve",
|
||||||
|
) {
|
||||||
|
if (prefixMode === "preserve") {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...headers }
|
||||||
|
const locationHeader = next.location
|
||||||
|
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||||
|
if (!location) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||||
|
|
||||||
|
if (location.startsWith("/")) {
|
||||||
|
next.location = `${publicBase}${location}`
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(location)
|
||||||
|
if (parsed.origin === targetOrigin) {
|
||||||
|
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relative redirects should continue to resolve against the public sidecar path.
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSideCarProxyRequestHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
targetOrigin: string,
|
||||||
|
): Record<string, string | string[] | undefined> {
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
const next: Record<string, string | string[] | undefined> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!value) continue
|
||||||
|
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
next.origin = targetOrigin
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||||
|
return new Set([
|
||||||
|
"host",
|
||||||
|
"authorization",
|
||||||
|
"proxy-authorization",
|
||||||
|
"forwarded",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-port",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|||||||
56
packages/server/src/server/routes/sidecars.ts
Normal file
56
packages/server/src/server/routes/sidecars.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { SideCarManager } from "../../sidecars/manager"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideCarCreateSchema = z.object({
|
||||||
|
kind: z.literal("port").default("port"),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
insecure: z.boolean().default(false),
|
||||||
|
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: "At least one field is required",
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/sidecars", async () => {
|
||||||
|
return { sidecars: await deps.sidecarManager.list() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/sidecars", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarCreateSchema.parse(request.body ?? {})
|
||||||
|
const sidecar = await deps.sidecarManager.create(body)
|
||||||
|
reply.code(201)
|
||||||
|
return sidecar
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarUpdateSchema.parse(request.body ?? {})
|
||||||
|
return await deps.sidecarManager.update(request.params.id, body)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
const removed = await deps.sidecarManager.delete(request.params.id)
|
||||||
|
if (!removed) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "SideCar not found" }
|
||||||
|
}
|
||||||
|
reply.code(204)
|
||||||
|
})
|
||||||
|
}
|
||||||
256
packages/server/src/sidecars/manager.ts
Normal file
256
packages/server/src/sidecars/manager.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { connect } from "net"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
|
||||||
|
|
||||||
|
interface SideCarManagerOptions {
|
||||||
|
settings: SettingsService
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarConfigRecord {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarRuntimeRecord {
|
||||||
|
status: SideCarStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SideCarManager {
|
||||||
|
private readonly configs = new Map<string, SideCarConfigRecord>()
|
||||||
|
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
|
||||||
|
|
||||||
|
constructor(private readonly options: SideCarManagerOptions) {
|
||||||
|
for (const record of this.loadConfiguredSideCars()) {
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
for (const record of this.configs.values()) {
|
||||||
|
void this.refreshPortSideCar(record.id).catch((error) => {
|
||||||
|
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<SideCar[]> {
|
||||||
|
await this.refreshPortStatuses()
|
||||||
|
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<SideCar | undefined> {
|
||||||
|
if (!this.configs.has(id)) return undefined
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(this.requireConfig(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}): Promise<SideCar> {
|
||||||
|
const normalizedName = input.name.trim()
|
||||||
|
const id = this.buildSideCarId(normalizedName)
|
||||||
|
if (this.configs.has(id)) {
|
||||||
|
throw new Error(`SideCar '${id}' already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const record: SideCarConfigRecord = {
|
||||||
|
id,
|
||||||
|
kind: input.kind,
|
||||||
|
name: normalizedName,
|
||||||
|
port: input.port,
|
||||||
|
insecure: input.insecure,
|
||||||
|
prefixMode: input.prefixMode,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(record.id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: Partial<{
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}>,
|
||||||
|
): Promise<SideCar> {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
|
||||||
|
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||||
|
record.port = typeof input.port === "number" ? input.port : record.port
|
||||||
|
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||||
|
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return false
|
||||||
|
|
||||||
|
this.configs.delete(id)
|
||||||
|
this.runtime.delete(id)
|
||||||
|
this.persistConfigs()
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
|
||||||
|
const protocol = sidecar.insecure ? "http" : "https"
|
||||||
|
return `${protocol}://127.0.0.1:${sidecar.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildProxyBasePath(id: string): string {
|
||||||
|
return `/sidecars/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetPath(id: string, incomingPath: string, search = ""): string {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
const publicBase = this.buildProxyBasePath(id)
|
||||||
|
const normalizedPath = incomingPath || publicBase
|
||||||
|
|
||||||
|
if (record.prefixMode === "preserve") {
|
||||||
|
return `${normalizedPath}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
|
||||||
|
if (!stripped || stripped === "/") {
|
||||||
|
stripped = "/"
|
||||||
|
} else if (!stripped.startsWith("/")) {
|
||||||
|
stripped = `/${stripped}`
|
||||||
|
}
|
||||||
|
return `${stripped}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortStatuses() {
|
||||||
|
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortSideCar(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
const isAvailable = await this.isPortAvailable(record.port)
|
||||||
|
const current = this.runtime.get(id)
|
||||||
|
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
|
||||||
|
if (current?.status === nextStatus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtime.set(id, { status: nextStatus })
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
this.publish(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSideCar(record: SideCarConfigRecord): SideCar {
|
||||||
|
const runtime = this.runtime.get(record.id)
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
kind: record.kind,
|
||||||
|
name: record.name,
|
||||||
|
port: record.port,
|
||||||
|
insecure: record.insecure,
|
||||||
|
prefixMode: record.prefixMode,
|
||||||
|
status: runtime?.status ?? "stopped",
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireConfig(id: string): SideCarConfigRecord {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) {
|
||||||
|
throw new Error("SideCar not found")
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistConfigs() {
|
||||||
|
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
|
||||||
|
this.options.settings.mergePatchOwner("config", "server", { sidecars })
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfiguredSideCars(): SideCarConfigRecord[] {
|
||||||
|
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
|
||||||
|
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
|
||||||
|
const records: SideCarConfigRecord[] = []
|
||||||
|
for (const item of list) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
const kind = record.kind === "port" ? "port" : null
|
||||||
|
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
|
||||||
|
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
|
||||||
|
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
|
||||||
|
if (!kind || !id || !name || !port) continue
|
||||||
|
|
||||||
|
const insecure = record.insecure === true
|
||||||
|
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
|
||||||
|
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
|
||||||
|
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
|
||||||
|
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||||
|
socket.end()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
socket.once("error", () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSideCarId(name: string): string {
|
||||||
|
const normalized = name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("SideCar name must include letters or numbers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -963,6 +963,7 @@ impl CliEntry {
|
|||||||
"--auth-cookie-name".to_string(),
|
"--auth-cookie-name".to_string(),
|
||||||
auth_cookie_name.to_string(),
|
auth_cookie_name.to_string(),
|
||||||
"--generate-token".to_string(),
|
"--generate-token".to_string(),
|
||||||
|
"--unrestricted-root".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
|
|||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
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 { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
|
import { showAlertDialog } from "./stores/alerts"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
|
|||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
setIsSelectingFolder,
|
setIsSelectingFolder,
|
||||||
showFolderSelection,
|
showFolderSelection,
|
||||||
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
|
|||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
instances,
|
instances,
|
||||||
activeInstanceId,
|
|
||||||
setActiveInstanceId,
|
|
||||||
stopInstance,
|
stopInstance,
|
||||||
getActiveInstance,
|
|
||||||
disconnectedInstance,
|
disconnectedInstance,
|
||||||
acknowledgeDisconnectedInstance,
|
acknowledgeDisconnectedInstance,
|
||||||
} from "./stores/instances"
|
} from "./stores/instances"
|
||||||
@@ -53,6 +52,22 @@ import {
|
|||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
import { openSettings } from "./stores/settings-screen"
|
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")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -77,6 +92,7 @@ const App: Component = () => {
|
|||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
|
||||||
|
|
||||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||||
@@ -206,8 +222,7 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
instances()
|
appTabs()
|
||||||
hasInstances()
|
|
||||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,7 +234,15 @@ const App: Component = () => {
|
|||||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
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 activeSessionIdForInstance = createMemo(() => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
if (!instance) return null
|
if (!instance) return null
|
||||||
@@ -244,6 +267,7 @@ const App: Component = () => {
|
|||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
|
selectInstanceTab(instanceId)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
@@ -270,8 +294,27 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
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) => {
|
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||||
if (!instanceId || !sessionId || sessionId === "info") return
|
if (!instanceId || !sessionId || sessionId === "info") return
|
||||||
await updateSessionAgent(instanceId, sessionId, agent)
|
await updateSessionAgent(instanceId, sessionId, agent)
|
||||||
@@ -361,6 +421,7 @@ const App: Component = () => {
|
|||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
setToolInputsVisibility,
|
setToolInputsVisibility,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -371,6 +432,7 @@ const App: Component = () => {
|
|||||||
useAppLifecycle({
|
useAppLifecycle({
|
||||||
setEscapeInDebounce,
|
setEscapeInDebounce,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -470,52 +532,60 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!hasInstances()}
|
when={appTabs().length === 0}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
<InstanceTabs
|
<InstanceTabs
|
||||||
instances={instances()}
|
tabs={appTabs()}
|
||||||
activeInstanceId={activeInstanceId()}
|
activeTabId={activeAppTabId()}
|
||||||
onSelect={setActiveInstanceId}
|
onSelect={selectAppTab}
|
||||||
onClose={handleCloseInstance}
|
onClose={(tabId) => void handleCloseAppTab(tabId)}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</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>
|
</For>
|
||||||
|
|
||||||
@@ -525,6 +595,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -534,6 +605,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
@@ -544,6 +616,7 @@ const App: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<SettingsScreen />
|
<SettingsScreen />
|
||||||
|
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type HomeTab = "local" | "servers"
|
|||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
|
onOpenSidecar?: () => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
@@ -845,32 +846,43 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body flex flex-col gap-3">
|
<div class="panel-body flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleBrowse()}
|
onClick={() => void handleBrowse()}
|
||||||
disabled={props.isLoading}
|
disabled={props.isLoading}
|
||||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||||
onMouseEnter={() => setFocusMode("new")}
|
onMouseEnter={() => setFocusMode("new")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<FolderPlus class="w-4 h-4" />
|
<FolderPlus class="w-4 h-4" />
|
||||||
<span>
|
<span>
|
||||||
{props.isLoading
|
{props.isLoading
|
||||||
? t("folderSelection.browse.buttonOpening")
|
? t("folderSelection.browse.buttonOpening")
|
||||||
: t("folderSelection.browse.button")}
|
: t("folderSelection.browse.button")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={openServerDialog}
|
type="button"
|
||||||
class="button-primary w-full flex items-center justify-center text-sm"
|
onClick={() => props.onOpenSidecar?.()}
|
||||||
>
|
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
|
||||||
<div class="flex items-center gap-2">
|
>
|
||||||
<Globe class="w-4 h-4" />
|
<div class="flex items-center gap-2">
|
||||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
<MonitorUp class="w-4 h-4" />
|
||||||
</div>
|
<span>{t("folderSelection.sidecars.button")}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* OpenCode settings section */}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, For, Show, createMemo } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
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 { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
import { openSettings } from "../stores/settings-screen"
|
||||||
|
import type { AppTabRecord } from "../stores/app-tabs"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
tabs: AppTabRecord[]
|
||||||
activeInstanceId: string | null
|
activeTabId: string | null
|
||||||
onSelect: (instanceId: string) => void
|
onSelect: (tabId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (tabId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<div class="tab-scroll">
|
<div class="tab-scroll">
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
<div class="tab-strip-tabs">
|
<div class="tab-strip-tabs">
|
||||||
<For each={Array.from(props.instances.entries())}>
|
<For each={props.tabs}>
|
||||||
{([id, instance]) => (
|
{(tab) =>
|
||||||
<InstanceTab
|
tab.kind === "instance" ? (
|
||||||
instance={instance}
|
<InstanceTab
|
||||||
active={id === props.activeInstanceId}
|
instance={tab.instance}
|
||||||
onSelect={() => props.onSelect(id)}
|
active={tab.id === props.activeTabId}
|
||||||
onClose={() => props.onClose(id)}
|
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>
|
</For>
|
||||||
<button
|
<button
|
||||||
class="new-tab-button"
|
class="new-tab-button"
|
||||||
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-strip-spacer" />
|
<div class="tab-strip-spacer" />
|
||||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
<Show when={props.tabs.length > 1}>
|
||||||
<div class="tab-shortcuts">
|
<div class="tab-shortcuts">
|
||||||
<KeyboardHint
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
|
||||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
@@ -9,13 +9,14 @@ import type { MessageRecord } from "../stores/message-v2/types"
|
|||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { selectInstanceTab } from "../stores/app-tabs"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
function DeleteUpToIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
|
|||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
|
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||||
setActiveInstanceId(location.instanceId)
|
selectInstanceTab(location.instanceId)
|
||||||
const parentToActivate = location.parentId ?? location.sessionId
|
const parentToActivate = location.parentId ?? location.sessionId
|
||||||
setActiveParentSession(location.instanceId, parentToActivate)
|
setActiveParentSession(location.instanceId, parentToActivate)
|
||||||
if (location.parentId) {
|
if (location.parentId) {
|
||||||
@@ -803,19 +805,19 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
data-message-id={resolvedBlock().record.id}
|
data-message-id={resolvedBlock().record.id}
|
||||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<For each={resolvedBlock().items}>
|
<Index each={resolvedBlock().items}>
|
||||||
{(item, index) => (
|
{(item, index) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={item.type === "content"}>
|
<Match when={item().type === "content"}>
|
||||||
<MessageContentItem
|
<MessageContentItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={(item as ContentDisplayItem).messageId}
|
messageId={(item() as ContentDisplayItem).messageId}
|
||||||
startPartId={(item as ContentDisplayItem).startPartId}
|
startPartId={(item() as ContentDisplayItem).startPartId}
|
||||||
messageIndex={props.messageIndex}
|
messageIndex={props.messageIndex}
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -825,18 +827,18 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "tool"}>
|
<Match when={item().type === "tool"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const toolItem = item as ToolDisplayItem
|
const toolItem = item() as ToolDisplayItem
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-message" data-key={toolItem.key}>
|
<div class="tool-call-message" data-key={toolItem.key}>
|
||||||
<ToolCallItem
|
<ToolCallItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={toolItem.messageId}
|
messageId={toolItem.messageId}
|
||||||
partId={toolItem.partId}
|
partId={toolItem.partId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
deleteHover={props.deleteHover}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -849,13 +851,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-start"}>
|
<Match when={item().type === "step-start"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="start"
|
kind="start"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showAgentMeta
|
showAgentMeta
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -865,14 +867,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-finish"}>
|
<Match when={item().type === "step-finish"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="finish"
|
kind="finish"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showUsage={props.showUsageMetrics()}
|
showUsage={props.showUsageMetrics()}
|
||||||
borderColor={(item as StepDisplayItem).accentColor}
|
borderColor={(item() as StepDisplayItem).accentColor}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -882,31 +884,31 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "compaction"}>
|
<Match when={item().type === "compaction"}>
|
||||||
<CompactionCard
|
<CompactionCard
|
||||||
part={(item as CompactionDisplayItem).part}
|
part={(item() as CompactionDisplayItem).part}
|
||||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
messageInfo={(item() as CompactionDisplayItem).messageInfo}
|
||||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
borderColor={(item() as CompactionDisplayItem).accentColor}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as CompactionDisplayItem).messageId}
|
messageId={(item() as CompactionDisplayItem).messageId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "reasoning"}>
|
<Match when={item().type === "reasoning"}>
|
||||||
<ReasoningCard
|
<ReasoningCard
|
||||||
part={(item as ReasoningDisplayItem).part}
|
part={(item() as ReasoningDisplayItem).part}
|
||||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as ReasoningDisplayItem).messageId}
|
messageId={(item() as ReasoningDisplayItem).messageId}
|
||||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
|
||||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
@@ -916,7 +918,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1293,14 +1295,23 @@ interface ReasoningCardProps {
|
|||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningStreamOutput(props: {
|
||||||
const { t } = useI18n()
|
text: Accessor<string>
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
scrollTopSnapshot: Accessor<number>
|
||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
setScrollTopSnapshot: (next: number) => void
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
onContentRendered?: () => void
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
ariaLabel: string
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
let pendingRenderNotificationFrame: number | null = null
|
let pendingRenderNotificationFrame: number | null = null
|
||||||
|
|
||||||
|
const followScroll = createFollowScroll({
|
||||||
|
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||||
|
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||||
|
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
|
||||||
|
sentinelClassName: "reasoning-scroll-sentinel",
|
||||||
|
})
|
||||||
|
|
||||||
const notifyContentRendered = () => {
|
const notifyContentRendered = () => {
|
||||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
@@ -1312,6 +1323,17 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const nextText = props.text()
|
||||||
|
if (preRef && preRef.textContent !== nextText) {
|
||||||
|
preRef.textContent = nextText
|
||||||
|
}
|
||||||
|
if (followScroll.autoScroll()) {
|
||||||
|
followScroll.restoreAfterRender({ forceBottom: true })
|
||||||
|
}
|
||||||
|
notifyContentRendered()
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||||
@@ -1319,6 +1341,37 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={followScroll.registerContainer}
|
||||||
|
class="message-reasoning-output"
|
||||||
|
role="region"
|
||||||
|
aria-label={props.ariaLabel}
|
||||||
|
onScroll={followScroll.handleScroll}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
ref={(element) => {
|
||||||
|
preRef = element || undefined
|
||||||
|
if (preRef) {
|
||||||
|
preRef.textContent = props.text() || ""
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="message-reasoning-text"
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
{followScroll.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||||
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1393,12 +1446,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!expanded()) return
|
|
||||||
reasoningText()
|
|
||||||
notifyContentRendered()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
@@ -1553,9 +1600,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
<ReasoningStreamOutput
|
||||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
text={reasoningText}
|
||||||
</div>
|
scrollTopSnapshot={scrollTopSnapshot}
|
||||||
|
setScrollTopSnapshot={setScrollTopSnapshot}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
|
||||||
|
|
||||||
const lastCompactionIndex = createMemo(() => {
|
const lastCompactionIndex = createMemo(() => {
|
||||||
// Depend on a single session revision signal (not every message/part read)
|
// Depend on a single session revision signal (not every message/part read)
|
||||||
// to keep reactive overhead small.
|
// to keep reactive overhead small.
|
||||||
@@ -315,15 +317,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastAssistantIndex = createMemo(() => {
|
const lastAssistantIndex = createMemo(() => {
|
||||||
const ids = messageIds()
|
const messageId = lastAssistantMessageId()
|
||||||
const resolvedStore = store()
|
if (!messageId) return -1
|
||||||
for (let index = ids.length - 1; index >= 0; index--) {
|
return messageIndexById().get(messageId) ?? -1
|
||||||
const record = resolvedStore.getMessage(ids[index])
|
|
||||||
if (record?.role === "assistant") {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||||
@@ -734,88 +730,93 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const loading = Boolean(props.loading)
|
const loading = Boolean(props.loading)
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
|
|
||||||
if (loading) {
|
// Wrap all iteration of the store-proxied `ids` array in untrack()
|
||||||
handleClearTimelineSelection()
|
// to prevent O(n) per-element reactive subscriptions. The effect
|
||||||
previousTimelineIds = []
|
// only needs to re-run when `messageIds` (memo) changes.
|
||||||
setTimelineSegments([])
|
untrack(() => {
|
||||||
seenTimelineMessageIds.clear()
|
if (loading) {
|
||||||
seenTimelineSegmentKeys.clear()
|
handleClearTimelineSelection()
|
||||||
timelinePartCountsByMessageId.clear()
|
previousTimelineIds = []
|
||||||
pendingTimelineMessagePartUpdates.clear()
|
setTimelineSegments([])
|
||||||
if (pendingTimelinePartUpdateFrame !== null) {
|
seenTimelineMessageIds.clear()
|
||||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
seenTimelineSegmentKeys.clear()
|
||||||
pendingTimelinePartUpdateFrame = null
|
timelinePartCountsByMessageId.clear()
|
||||||
}
|
pendingTimelineMessagePartUpdates.clear()
|
||||||
return
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
}
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
|
||||||
seedTimeline()
|
|
||||||
previousTimelineIds = ids.slice()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.length < previousTimelineIds.length) {
|
|
||||||
seedTimeline()
|
|
||||||
previousTimelineIds = ids.slice()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.length === previousTimelineIds.length) {
|
|
||||||
let changedIndex = -1
|
|
||||||
let changeCount = 0
|
|
||||||
for (let index = 0; index < ids.length; index++) {
|
|
||||||
if (ids[index] !== previousTimelineIds[index]) {
|
|
||||||
changedIndex = index
|
|
||||||
changeCount += 1
|
|
||||||
if (changeCount > 1) break
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (changeCount === 1 && changedIndex >= 0) {
|
|
||||||
const oldId = previousTimelineIds[changedIndex]
|
|
||||||
const newId = ids[changedIndex]
|
|
||||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
|
||||||
seenTimelineMessageIds.delete(oldId)
|
|
||||||
seenTimelineMessageIds.add(newId)
|
|
||||||
setTimelineSegments((prev) => {
|
|
||||||
const next = prev.map((segment) => {
|
|
||||||
if (segment.messageId !== oldId) return segment
|
|
||||||
const updatedId = segment.id.replace(oldId, newId)
|
|
||||||
return { ...segment, messageId: newId, id: updatedId }
|
|
||||||
})
|
|
||||||
seenTimelineSegmentKeys.clear()
|
|
||||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep part count tracking in sync with id replacement.
|
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
seedTimeline()
|
||||||
if (existingPartCount !== undefined) {
|
previousTimelineIds = [...ids]
|
||||||
timelinePartCountsByMessageId.delete(oldId)
|
return
|
||||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
}
|
||||||
|
|
||||||
|
if (ids.length < previousTimelineIds.length) {
|
||||||
|
seedTimeline()
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === previousTimelineIds.length) {
|
||||||
|
let changedIndex = -1
|
||||||
|
let changeCount = 0
|
||||||
|
for (let index = 0; index < ids.length; index++) {
|
||||||
|
if (ids[index] !== previousTimelineIds[index]) {
|
||||||
|
changedIndex = index
|
||||||
|
changeCount += 1
|
||||||
|
if (changeCount > 1) break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (changeCount === 1 && changedIndex >= 0) {
|
||||||
|
const oldId = previousTimelineIds[changedIndex]
|
||||||
|
const newId = ids[changedIndex]
|
||||||
|
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||||
|
seenTimelineMessageIds.delete(oldId)
|
||||||
|
seenTimelineMessageIds.add(newId)
|
||||||
|
setTimelineSegments((prev) => {
|
||||||
|
const next = prev.map((segment) => {
|
||||||
|
if (segment.messageId !== oldId) return segment
|
||||||
|
const updatedId = segment.id.replace(oldId, newId)
|
||||||
|
return { ...segment, messageId: newId, id: updatedId }
|
||||||
|
})
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
// Keep part count tracking in sync with id replacement.
|
||||||
return
|
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||||
|
if (existingPartCount !== undefined) {
|
||||||
|
timelinePartCountsByMessageId.delete(oldId)
|
||||||
|
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const newIds: string[] = []
|
const newIds: string[] = []
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
if (!seenTimelineMessageIds.has(id)) {
|
if (!seenTimelineMessageIds.has(id)) {
|
||||||
newIds.push(id)
|
newIds.push(id)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (newIds.length > 0) {
|
|
||||||
newIds.forEach((id) => {
|
|
||||||
seenTimelineMessageIds.add(id)
|
|
||||||
appendTimelineForMessage(id)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
if (newIds.length > 0) {
|
||||||
|
newIds.forEach((id) => {
|
||||||
|
seenTimelineMessageIds.add(id)
|
||||||
|
appendTimelineForMessage(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearPendingTimelinePartUpdateFrame() {
|
function clearPendingTimelinePartUpdateFrame() {
|
||||||
@@ -886,36 +887,49 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
const resolvedStore = store()
|
// Also re-run when sessionRevision bumps (covers part additions within
|
||||||
|
// existing messages) but read individual records inside untrack() to
|
||||||
|
// avoid creating O(n) fine-grained subscriptions.
|
||||||
|
sessionRevision()
|
||||||
|
|
||||||
let hasChanges = false
|
// Wrap the iteration in untrack() so that accessing individual elements
|
||||||
for (const messageId of ids) {
|
// of the store-proxied `ids` array does not create O(n) per-element
|
||||||
const record = resolvedStore.getMessage(messageId)
|
// reactive subscriptions. We only need to re-run when the memo
|
||||||
const partCount = record?.partIds.length ?? 0
|
// (messageIds) or sessionRevision changes — not per-element.
|
||||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
untrack(() => {
|
||||||
|
const resolvedStore = store()
|
||||||
|
const idsSet = new Set(ids)
|
||||||
|
let hasChanges = false
|
||||||
|
|
||||||
if (previousCount === undefined) {
|
for (const messageId of ids) {
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
continue
|
const partCount = record?.partIds.length ?? 0
|
||||||
|
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||||
|
|
||||||
|
if (previousCount === undefined) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousCount !== partCount) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
pendingTimelineMessagePartUpdates.add(messageId)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousCount !== partCount) {
|
// Drop tracking for ids that are no longer present.
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
|
||||||
pendingTimelineMessagePartUpdates.add(messageId)
|
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||||
hasChanges = true
|
if (!idsSet.has(trackedId)) {
|
||||||
|
timelinePartCountsByMessageId.delete(trackedId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Drop tracking for ids that are no longer present.
|
if (hasChanges) {
|
||||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
scheduleTimelinePartUpdateFlush()
|
||||||
if (!ids.includes(trackedId)) {
|
|
||||||
timelinePartCountsByMessageId.delete(trackedId)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
scheduleTimelinePartUpdateFlush()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -540,6 +540,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
mode={pickerMode()}
|
mode={pickerMode()}
|
||||||
onClose={handlePickerClose}
|
onClose={handlePickerClose}
|
||||||
onSelect={handlePickerSelect}
|
onSelect={handlePickerSelect}
|
||||||
|
onSubmitWithoutSelection={() => {
|
||||||
|
handlePickerClose()
|
||||||
|
void handleSend()
|
||||||
|
}}
|
||||||
agents={instanceAgents()}
|
agents={instanceAgents()}
|
||||||
commands={getCommands(props.instanceId)}
|
commands={getCommands(props.instanceId)}
|
||||||
instanceClient={instance()!.client}
|
instanceClient={instance()!.client}
|
||||||
|
|||||||
@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pickerMode() === "mention" && pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
|
|
||||||
// Remove the partial @mention text from the textarea when ESC is pressed
|
|
||||||
const textarea = options.getTextarea()
|
|
||||||
if (textarea) {
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const cursorPos = textarea.selectionStart
|
|
||||||
// Remove text from @ position to cursor position
|
|
||||||
const before = currentPrompt.substring(0, pos)
|
|
||||||
const after = currentPrompt.substring(cursorPos)
|
|
||||||
options.setPrompt(before + after)
|
|
||||||
|
|
||||||
// Restore cursor position to where @ was
|
|
||||||
setTimeout(() => {
|
|
||||||
const nextTextarea = options.getTextarea()
|
|
||||||
if (nextTextarea) {
|
|
||||||
nextTextarea.setSelectionRange(pos, pos)
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
// Clear ignoredAtPositions so typing @ again will work
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
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 { createMemo, For, type Component } from "solid-js"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
|
|||||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||||
|
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
export const SettingsScreen: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
|
|||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
{ 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") },
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
|
|||||||
return <RemoteAccessSettingsSection />
|
return <RemoteAccessSettingsSection />
|
||||||
case "speech":
|
case "speech":
|
||||||
return <SpeechSettingsSection />
|
return <SpeechSettingsSection />
|
||||||
|
case "sidecars":
|
||||||
|
return <SideCarsSettingsSection />
|
||||||
case "opencode":
|
case "opencode":
|
||||||
return <OpenCodeSettingsSection />
|
return <OpenCodeSettingsSection />
|
||||||
case "appearance":
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
||||||
import { stringify as stringifyYaml } from "yaml"
|
import { stringify as stringifyYaml } from "yaml"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
|||||||
|
|
||||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
|
||||||
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
|
||||||
|
|
||||||
function makeRenderCacheKey(
|
function makeRenderCacheKey(
|
||||||
toolCallId?: string | null,
|
toolCallId?: string | null,
|
||||||
@@ -82,6 +81,27 @@ interface ToolCallProps {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ToolStatusIndicator(props: { status: Accessor<string> }) {
|
||||||
|
const isVisible = (value: string) => props.status() === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
|
||||||
|
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
|
||||||
|
<Hourglass class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
|
||||||
|
<XCircle class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ToolCallDetails(props: {
|
function ToolCallDetails(props: {
|
||||||
toolCallMemo: () => ToolCallPart
|
toolCallMemo: () => ToolCallPart
|
||||||
toolState: () => ToolState | undefined
|
toolState: () => ToolState | undefined
|
||||||
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
|
|||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
const followScroll = createFollowScroll({
|
||||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
|
||||||
|
sentinelClassName: "tool-call-scroll-sentinel",
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
})
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
|
||||||
|
|
||||||
let pendingScrollFrame: number | null = null
|
|
||||||
let pendingAnchorScroll: number | null = null
|
|
||||||
let userScrollIntentUntil = 0
|
|
||||||
let lastKnownScrollTop = props.scrollTopSnapshot()
|
|
||||||
|
|
||||||
function restoreScrollPosition(forceBottom = false) {
|
|
||||||
const container = scrollContainerRef
|
|
||||||
if (!container) return
|
|
||||||
if (forceBottom) {
|
|
||||||
container.scrollTop = container.scrollHeight
|
|
||||||
lastKnownScrollTop = container.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
} else {
|
|
||||||
container.scrollTop = lastKnownScrollTop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistScrollSnapshot = (element?: HTMLElement | null) => {
|
|
||||||
if (!element) return
|
|
||||||
lastKnownScrollTop = element.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
function markUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
return now <= userScrollIntentUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
const handlePointerIntent = () => markUserScrollIntent()
|
|
||||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
|
||||||
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
|
|
||||||
markUserScrollIntent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("keydown", handleKeyIntent)
|
|
||||||
detachScrollIntentListeners = () => {
|
|
||||||
element.removeEventListener("wheel", handlePointerIntent)
|
|
||||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.removeEventListener("touchstart", handlePointerIntent)
|
|
||||||
element.removeEventListener("keydown", handleKeyIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAnchorScroll(immediate = false) {
|
|
||||||
if (!autoScroll()) return
|
|
||||||
const sentinel = bottomSentinel()
|
|
||||||
const container = scrollContainerRef
|
|
||||||
if (!sentinel || !container) return
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
|
||||||
const sentinelRect = sentinel.getBoundingClientRect()
|
|
||||||
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
|
|
||||||
if (Math.abs(delta) > 1) {
|
|
||||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
|
||||||
}
|
|
||||||
lastKnownScrollTop = container.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
const container = scrollContainer()
|
|
||||||
if (!container) return
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
}
|
|
||||||
const isUserScroll = hasUserScrollIntent()
|
|
||||||
pendingScrollFrame = requestAnimationFrame(() => {
|
|
||||||
pendingScrollFrame = null
|
|
||||||
const atBottom = bottomSentinelVisible()
|
|
||||||
if (isUserScroll) {
|
|
||||||
if (atBottom) {
|
|
||||||
if (!autoScroll()) setAutoScroll(true)
|
|
||||||
} else if (autoScroll()) {
|
|
||||||
setAutoScroll(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
||||||
handleScroll()
|
|
||||||
persistScrollSnapshot(event.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScrollRendered = () => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
restoreScrollPosition(autoScroll())
|
|
||||||
scheduleAnchorScroll(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
|
||||||
const next = element || undefined
|
|
||||||
if (next === scrollContainerRef) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollContainerRef = next
|
|
||||||
setScrollContainer(scrollContainerRef)
|
|
||||||
if (scrollContainerRef) {
|
|
||||||
// Refresh our snapshot on mount (e.g. when remounting after collapse)
|
|
||||||
lastKnownScrollTop = props.scrollTopSnapshot()
|
|
||||||
restoreScrollPosition(autoScroll())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollHelpers: ToolScrollHelpers = {
|
const scrollHelpers: ToolScrollHelpers = {
|
||||||
registerContainer: (element, options) => {
|
registerContainer: (element, options) => {
|
||||||
if (options?.disableTracking) return
|
followScroll.registerContainer(element, options)
|
||||||
initializeScrollContainer(element)
|
|
||||||
},
|
|
||||||
handleScroll: handleScrollEvent,
|
|
||||||
renderSentinel: (options) => {
|
|
||||||
if (options?.disableTracking) return null
|
|
||||||
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
|
||||||
},
|
},
|
||||||
|
handleScroll: followScroll.handleScroll,
|
||||||
|
renderSentinel: followScroll.renderSentinel,
|
||||||
|
restoreAfterRender: followScroll.restoreAfterRender,
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
const handleScrollRendered = () => {
|
||||||
const container = scrollContainer()
|
scrollHelpers.restoreAfterRender()
|
||||||
if (!container) return
|
}
|
||||||
attachScrollIntentListeners(container)
|
|
||||||
onCleanup(() => {
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const container = scrollContainer()
|
|
||||||
const sentinel = bottomSentinel()
|
|
||||||
if (!container || !sentinel) return
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.target === sentinel) {
|
|
||||||
setBottomSentinelVisible(entry.isIntersecting)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
|
||||||
)
|
|
||||||
observer.observe(sentinel)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
|
|||||||
partVersion={options.partVersion}
|
partVersion={options.partVersion}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={options.sessionId}
|
sessionId={options.sessionId}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
forceCollapsed={options.forceCollapsed}
|
forceCollapsed={options.forceCollapsed}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollHelpers,
|
scrollHelpers,
|
||||||
|
onContentRendered: props.onContentRendered,
|
||||||
}
|
}
|
||||||
|
|
||||||
let previousPartVersion: number | undefined
|
let previousPartVersion: number | undefined
|
||||||
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousPartVersion = version
|
previousPartVersion = version
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (autoScroll()) {
|
if (followScroll.autoScroll()) {
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender({ forceBottom: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
pendingScrollFrame = null
|
|
||||||
}
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-details">
|
<div class="tool-call-details">
|
||||||
<Show
|
<Show
|
||||||
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return !current
|
return !current
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const statusIcon = () => {
|
|
||||||
const status = toolState()?.status || ""
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return <Hourglass class="w-4 h-4" />
|
|
||||||
case "running":
|
|
||||||
return <Loader2 class="w-4 h-4 animate-spin" />
|
|
||||||
case "completed":
|
|
||||||
return <Check class="w-4 h-4" />
|
|
||||||
case "error":
|
|
||||||
return <XCircle class="w-4 h-4" />
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClass = () => {
|
const statusClass = () => {
|
||||||
const status = toolState()?.status || "pending"
|
const status = toolState()?.status || "pending"
|
||||||
return `tool-call-status-${status}`
|
return `tool-call-status-${status}`
|
||||||
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<span class="tool-call-header-status" aria-hidden="true">
|
<ToolStatusIndicator status={status} />
|
||||||
{statusIcon()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/text-render-utils"
|
import { escapeHtml } from "../../lib/text-render-utils"
|
||||||
@@ -11,6 +11,97 @@ type CacheHandle = {
|
|||||||
set(value: unknown): void
|
set(value: unknown): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StableAnsiStreamUpdater {
|
||||||
|
update: (element: HTMLElement, content: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
|
||||||
|
const renderer = createAnsiStreamRenderer()
|
||||||
|
let previousContent = ""
|
||||||
|
let ansiActive = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(element: HTMLElement, content: string) {
|
||||||
|
const resetStreaming = !previousContent || !content.startsWith(previousContent)
|
||||||
|
|
||||||
|
if (resetStreaming) {
|
||||||
|
ansiActive = hasAnsi(content)
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = content.slice(previousContent.length)
|
||||||
|
if (delta.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ansiActive && hasAnsi(delta)) {
|
||||||
|
ansiActive = true
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = renderer.render(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ansiActive) {
|
||||||
|
const htmlChunk = renderer.render(delta)
|
||||||
|
if (htmlChunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", htmlChunk)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const escapedDelta = escapeHtml(delta)
|
||||||
|
if (escapedDelta.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", escapedDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousContent = content
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
previousContent = ""
|
||||||
|
ansiActive = false
|
||||||
|
renderer.reset()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamingAnsiContent(props: {
|
||||||
|
html: string
|
||||||
|
htmlChunk?: string
|
||||||
|
updateMode: "replace" | "append" | "noop"
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
if (props.updateMode === "noop") return
|
||||||
|
if (props.updateMode === "append") {
|
||||||
|
if (element.innerHTML.length === 0) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunk = props.htmlChunk ?? ""
|
||||||
|
if (chunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", chunk)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (element.innerHTML !== props.html) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
}
|
||||||
|
|
||||||
export function createAnsiContentRenderer(params: {
|
export function createAnsiContentRenderer(params: {
|
||||||
ansiRunningCache: CacheHandle
|
ansiRunningCache: CacheHandle
|
||||||
ansiFinalCache: CacheHandle
|
ansiFinalCache: CacheHandle
|
||||||
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const isRunningVariant = options.variant === "running"
|
const isRunningVariant = options.variant === "running"
|
||||||
const disableScrollTracking = !isRunningVariant
|
const disableScrollTracking = !isRunningVariant
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
let updateMode: "replace" | "append" | "noop" = "replace"
|
||||||
|
let htmlChunk = ""
|
||||||
|
|
||||||
let nextCache: AnsiRenderCache
|
let nextCache: AnsiRenderCache
|
||||||
|
|
||||||
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||||
|
|
||||||
if (resetStreaming) {
|
if (resetStreaming) {
|
||||||
|
updateMode = "replace"
|
||||||
const detectedAnsi = hasAnsi(content)
|
const detectedAnsi = hasAnsi(content)
|
||||||
if (detectedAnsi) {
|
if (detectedAnsi) {
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
|
|||||||
} else {
|
} else {
|
||||||
const delta = content.slice(cached.text.length)
|
const delta = content.slice(cached.text.length)
|
||||||
if (delta.length === 0) {
|
if (delta.length === 0) {
|
||||||
|
updateMode = "noop"
|
||||||
nextCache = { ...cached, mode }
|
nextCache = { ...cached, mode }
|
||||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||||
|
updateMode = "replace"
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
const html = runningAnsiRenderer.render(content)
|
const html = runningAnsiRenderer.render(content)
|
||||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||||
} else if (cached.hasAnsi) {
|
} else if (cached.hasAnsi) {
|
||||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
const appendedHtml = runningAnsiRenderer.render(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
updateMode = "append"
|
||||||
|
htmlChunk = appendedHtml
|
||||||
|
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
|
||||||
} else {
|
} else {
|
||||||
|
updateMode = "append"
|
||||||
|
htmlChunk = escapeHtml(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
||||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
<StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!disableScrollTracking) {
|
params.handleScrollRendered()
|
||||||
params.handleScrollRendered()
|
|
||||||
}
|
|
||||||
params.onContentRendered?.()
|
params.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,107 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { ToolRenderer, ToolScrollHelpers } from "../types"
|
||||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||||
import { tGlobal } from "../../../lib/i18n"
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
import { createStableAnsiStreamUpdater } from "../ansi-render"
|
||||||
|
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
|
||||||
|
|
||||||
|
function RunningBashOutput(props: {
|
||||||
|
content: Accessor<string>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
const updater = createStableAnsiStreamUpdater()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
updater.update(element, props.content())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
updater.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="message-text tool-call-markdown"
|
||||||
|
ref={props.scrollHelpers?.registerContainer}
|
||||||
|
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||||
|
>
|
||||||
|
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
{props.scrollHelpers?.renderSentinel?.()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BashToolBody(props: {
|
||||||
|
toolState: Accessor<ToolState | undefined>
|
||||||
|
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
const state = createMemo(() => props.toolState())
|
||||||
|
|
||||||
|
const joinedContent = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
if (!current || current.status === "pending") return ""
|
||||||
|
|
||||||
|
const { input, metadata } = readToolStatePayload(current)
|
||||||
|
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||||
|
const outputResult = formatUnknown(
|
||||||
|
isToolStateCompleted(current)
|
||||||
|
? current.output
|
||||||
|
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
|
||||||
|
? metadata.output
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
return [command, outputResult?.text].filter(Boolean).join("\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalMarkdown = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ensureMarkdownContent(content, "bash", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalAnsiHtml = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ansiToHtml(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={state() && joinedContent().length > 0}>
|
||||||
|
<Show
|
||||||
|
when={state()?.status === "running"}
|
||||||
|
fallback={
|
||||||
|
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
|
||||||
|
{(html) => (
|
||||||
|
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
|
||||||
|
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const bashRenderer: ToolRenderer = {
|
export const bashRenderer: ToolRenderer = {
|
||||||
tools: ["bash"],
|
tools: ["bash"],
|
||||||
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
|
|||||||
const timeoutLabel = `${timeout}ms`
|
const timeoutLabel = `${timeout}ms`
|
||||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
|
||||||
const state = toolState()
|
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
|
||||||
if (!state || state.status === "pending") return null
|
|
||||||
|
|
||||||
const { input, metadata } = readToolStatePayload(state)
|
|
||||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
|
||||||
const outputResult = formatUnknown(
|
|
||||||
isToolStateCompleted(state)
|
|
||||||
? state.output
|
|
||||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
|
||||||
? metadata.output
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
const parts = [command, outputResult?.text].filter(Boolean)
|
|
||||||
if (parts.length === 0) return null
|
|
||||||
|
|
||||||
const joined = parts.join("\n")
|
|
||||||
if (state.status === "running") {
|
|
||||||
return renderAnsi({ content: joined, variant: "running" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
|
|
||||||
if (ansiBody) {
|
|
||||||
return ansiBody
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = ensureMarkdownContent(joined, "bash", true)
|
|
||||||
if (!content) return null
|
|
||||||
|
|
||||||
return renderMarkdown({ content })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
return describeTaskTitle(input)
|
||||||
},
|
},
|
||||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||||
|
|
||||||
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const childCount = childToolKeys().length
|
||||||
|
const legacyCount = legacyItems().length
|
||||||
|
if (childCount === 0 && legacyCount === 0) return
|
||||||
|
scrollHelpers?.restoreAfterRender()
|
||||||
|
onContentRendered?.()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-task-sections">
|
<div class="tool-call-task-sections">
|
||||||
<Show when={promptContent()}>
|
<Show when={promptContent()}>
|
||||||
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="tool-call-task-summary">
|
<div class="tool-call-task-summary">
|
||||||
<For each={childToolKeys()}>
|
<Index each={childToolKeys()}>
|
||||||
{(key) => (
|
{(key) => (
|
||||||
<Show when={renderToolCall}>
|
<Show when={renderToolCall}>
|
||||||
{(render) => (
|
{(render) => (
|
||||||
<TaskToolCallRow
|
<TaskToolCallRow
|
||||||
toolKey={key}
|
toolKey={key()}
|
||||||
store={store}
|
store={store}
|
||||||
sessionId={childSessionId()}
|
sessionId={childSessionId()}
|
||||||
renderToolCall={render()}
|
renderToolCall={render()}
|
||||||
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
{scrollHelpers?.renderSentinel?.()}
|
{scrollHelpers?.renderSentinel?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
|
|||||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||||
|
restoreAfterRender(options?: { forceBottom?: boolean }): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRendererContext {
|
export interface ToolRendererContext {
|
||||||
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}) => JSXElement | null
|
}) => JSXElement | null
|
||||||
scrollHelpers?: ToolScrollHelpers
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRenderer {
|
export interface ToolRenderer {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
|
|||||||
mode?: "mention" | "command"
|
mode?: "mention" | "command"
|
||||||
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onSubmitWithoutSelection?: () => void
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
commands?: SDKCommand[]
|
commands?: SDKCommand[]
|
||||||
instanceClient: OpencodeClient | null
|
instanceClient: OpencodeClient | null
|
||||||
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
if (selected) {
|
if (selected) {
|
||||||
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||||
props.onSelect(selected, action)
|
props.onSelect(selected, action)
|
||||||
|
} else if (e.key === "Enter" && mode() === "mention") {
|
||||||
|
props.onSubmitWithoutSelection?.()
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
SpeechCapabilitiesResponse,
|
SpeechCapabilitiesResponse,
|
||||||
SpeechSynthesisResponse,
|
SpeechSynthesisResponse,
|
||||||
SpeechTranscriptionResponse,
|
SpeechTranscriptionResponse,
|
||||||
|
SideCar,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
RemoteServerProbeRequest,
|
RemoteServerProbeRequest,
|
||||||
RemoteServerProbeResponse,
|
RemoteServerProbeResponse,
|
||||||
@@ -193,6 +194,33 @@ export const serverApi = {
|
|||||||
body: JSON.stringify(payload),
|
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> {
|
fetchServerMeta(): Promise<ServerMeta> {
|
||||||
return request<ServerMeta>("/api/meta")
|
return request<ServerMeta>("/api/meta")
|
||||||
},
|
},
|
||||||
@@ -438,4 +466,4 @@ function buildClientEventsUrl(identity: { clientId: string; connectionId: string
|
|||||||
return `${url.pathname}${url.search}`
|
return `${url.pathname}${url.search}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }
|
||||||
|
|||||||
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
|
|
||||||
|
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
|
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
|
||||||
|
interface FollowScrollOptions {
|
||||||
|
getScrollTopSnapshot: Accessor<number>
|
||||||
|
setScrollTopSnapshot: (next: number) => void
|
||||||
|
sentinelMarginPx: number
|
||||||
|
sentinelClassName: string
|
||||||
|
intentWindowMs?: number
|
||||||
|
intentKeys?: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FollowScrollHelpers {
|
||||||
|
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
||||||
|
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
||||||
|
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
||||||
|
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
|
||||||
|
autoScroll: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
|
||||||
|
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||||
|
|
||||||
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
|
||||||
|
let pendingScrollFrame: number | null = null
|
||||||
|
let pendingAnchorScroll: number | null = null
|
||||||
|
let userScrollIntentUntil = 0
|
||||||
|
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
let pointerInteractionActive = false
|
||||||
|
let suppressNextScrollHandling = false
|
||||||
|
|
||||||
|
function restoreScrollPosition(forceBottom = false) {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (!container) return
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
if (forceBottom) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
} else {
|
||||||
|
container.scrollTop = lastKnownScrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistScrollSnapshot(element?: HTMLElement | null) {
|
||||||
|
if (!element) return
|
||||||
|
lastKnownScrollTop = element.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserScrollIntent() {
|
||||||
|
if (pointerInteractionActive) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
||||||
|
const handlePointerIntent = () => {
|
||||||
|
pointerInteractionActive = true
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
const clearPointerIntent = () => {
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||||
|
if (intentKeys.has(event.key)) {
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.addEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.addEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.addEventListener("touchend", clearPointerIntent)
|
||||||
|
window.addEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
detachScrollIntentListeners = () => {
|
||||||
|
element.removeEventListener("wheel", handlePointerIntent)
|
||||||
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.removeEventListener("touchstart", handlePointerIntent)
|
||||||
|
element.removeEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.removeEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.removeEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchend", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAnchorScroll(immediate = false) {
|
||||||
|
if (!autoScroll()) return
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (!sentinel || !container) return
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const sentinelRect = sentinel.getBoundingClientRect()
|
||||||
|
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
||||||
|
if (Math.abs(delta) > 1) {
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||||
|
}
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtBottom(container: HTMLDivElement) {
|
||||||
|
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
||||||
|
const container = containerOverride ?? scrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
if (suppressNextScrollHandling) {
|
||||||
|
suppressNextScrollHandling = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isUserScroll = hasUserScrollIntent()
|
||||||
|
const atBottomFromScroll = isAtBottom(container)
|
||||||
|
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
||||||
|
|
||||||
|
if (isUserScroll || !atBottom) {
|
||||||
|
if (atBottom) {
|
||||||
|
if (!autoScroll()) setAutoScroll(true)
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
|
updateFollowModeFromScroll(event.currentTarget)
|
||||||
|
persistScrollSnapshot(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
||||||
|
const next = element || undefined
|
||||||
|
if (next === scrollContainerRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollContainerRef = next
|
||||||
|
setScrollContainer(scrollContainerRef)
|
||||||
|
if (scrollContainerRef) {
|
||||||
|
lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
restoreScrollPosition(autoScroll())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSentinel = (config?: { disableTracking?: boolean }) => {
|
||||||
|
if (config?.disableTracking) return null
|
||||||
|
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||||
|
if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(false)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldFollow = config?.forceBottom ?? autoScroll()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(shouldFollow)
|
||||||
|
if (shouldFollow) {
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
attachScrollIntentListeners(container)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollContainer()
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
if (!container || !sentinel) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.target === sentinel) {
|
||||||
|
setBottomSentinelVisible(entry.isIntersecting)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
||||||
|
)
|
||||||
|
observer.observe(sentinel)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
pendingScrollFrame = null
|
||||||
|
}
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerContainer,
|
||||||
|
handleScroll,
|
||||||
|
renderSentinel,
|
||||||
|
restoreAfterRender,
|
||||||
|
autoScroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const log = getLogger("actions")
|
|||||||
interface UseAppLifecycleOptions {
|
interface UseAppLifecycleOptions {
|
||||||
setEscapeInDebounce: (value: boolean) => void
|
setEscapeInDebounce: (value: boolean) => void
|
||||||
handleNewInstanceRequest: () => void
|
handleNewInstanceRequest: () => void
|
||||||
|
handleCloseActiveTab: () => Promise<void>
|
||||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||||
handleNewSession: (instanceId: string) => Promise<void>
|
handleNewSession: (instanceId: string) => Promise<void>
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||||
@@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
|||||||
|
|
||||||
setupTabKeyboardShortcuts(
|
setupTabKeyboardShortcuts(
|
||||||
options.handleNewInstanceRequest,
|
options.handleNewInstanceRequest,
|
||||||
options.handleCloseInstance,
|
options.handleCloseActiveTab,
|
||||||
options.handleNewSession,
|
options.handleNewSession,
|
||||||
options.handleCloseSession,
|
options.handleCloseSession,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js"
|
|||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
||||||
import { createCommandRegistry, type Command } from "../commands"
|
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 type { ClientPart, MessageInfo } from "../../types/message"
|
||||||
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
@@ -41,6 +42,7 @@ export interface UseCommandsOptions {
|
|||||||
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||||
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
||||||
handleNewInstanceRequest: () => void
|
handleNewInstanceRequest: () => void
|
||||||
|
handleCloseActiveTab: () => Promise<void>
|
||||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||||
handleNewSession: (instanceId: string) => Promise<void>
|
handleNewSession: (instanceId: string) => Promise<void>
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||||
@@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
||||||
shortcut: { key: "W", meta: true },
|
shortcut: { key: "W", meta: true },
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
await options.handleCloseActiveTab()
|
||||||
if (!instance) return
|
|
||||||
await options.handleCloseInstance(instance.id)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
||||||
shortcut: { key: "]", meta: true },
|
shortcut: { key: "]", meta: true },
|
||||||
action: () => {
|
action: () => selectNextAppTab(),
|
||||||
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])
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
@@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
||||||
shortcut: { key: "[", meta: true },
|
shortcut: { key: "[", meta: true },
|
||||||
action: () => {
|
action: () => selectPreviousAppTab(),
|
||||||
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])
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Open folder picker to create new instance",
|
"commands.newInstance.description": "Open folder picker to create new instance",
|
||||||
"commands.newInstance.keywords": "folder, project, workspace",
|
"commands.newInstance.keywords": "folder, project, workspace",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Close Instance",
|
"commands.closeInstance.label": "Close Tab",
|
||||||
"commands.closeInstance.description": "Stop current instance's server",
|
"commands.closeInstance.description": "Close the current top-level tab",
|
||||||
"commands.closeInstance.keywords": "stop, quit, close",
|
"commands.closeInstance.keywords": "stop, quit, close, tab",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Next Instance",
|
"commands.nextInstance.label": "Next Tab",
|
||||||
"commands.nextInstance.description": "Cycle to next instance tab",
|
"commands.nextInstance.description": "Cycle to the next top-level tab",
|
||||||
"commands.nextInstance.keywords": "switch, navigate",
|
"commands.nextInstance.keywords": "switch, navigate, tab",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Previous Instance",
|
"commands.previousInstance.label": "Previous Tab",
|
||||||
"commands.previousInstance.description": "Cycle to previous instance tab",
|
"commands.previousInstance.description": "Cycle to the previous top-level tab",
|
||||||
"commands.previousInstance.keywords": "switch, navigate",
|
"commands.previousInstance.keywords": "switch, navigate, tab",
|
||||||
|
|
||||||
"commands.newSession.label": "New Session",
|
"commands.newSession.label": "New Session",
|
||||||
"commands.newSession.description": "Create a new parent session",
|
"commands.newSession.description": "Create a new parent session",
|
||||||
|
|||||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Connecting...",
|
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -195,4 +195,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Saved",
|
"settings.speech.save.saved": "Saved",
|
||||||
"settings.speech.save.unsaved": "Unsaved changes",
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
"settings.speech.save.error": "Save failed",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
||||||
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Cerrar instancia",
|
"commands.closeInstance.label": "Cerrar pestaña",
|
||||||
"commands.closeInstance.description": "Detener el servidor de la instancia actual",
|
"commands.closeInstance.description": "Cerrar la pestaña superior actual",
|
||||||
"commands.closeInstance.keywords": "detener, salir, cerrar",
|
"commands.closeInstance.keywords": "detener, salir, cerrar, pestaña",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Siguiente instancia",
|
"commands.nextInstance.label": "Siguiente pestaña",
|
||||||
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia",
|
"commands.nextInstance.description": "Cambiar a la siguiente pestaña superior",
|
||||||
"commands.nextInstance.keywords": "cambiar, navegar",
|
"commands.nextInstance.keywords": "cambiar, navegar, pestaña",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Instancia anterior",
|
"commands.previousInstance.label": "Pestaña anterior",
|
||||||
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior",
|
"commands.previousInstance.description": "Cambiar a la pestaña superior anterior",
|
||||||
"commands.previousInstance.keywords": "cambiar, navegar",
|
"commands.previousInstance.keywords": "cambiar, navegar, pestaña",
|
||||||
|
|
||||||
"commands.newSession.label": "Nueva sesión",
|
"commands.newSession.label": "Nueva sesión",
|
||||||
"commands.newSession.description": "Crear una nueva sesión principal",
|
"commands.newSession.description": "Crear una nueva sesión principal",
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.language.ariaLabel": "Idioma",
|
"folderSelection.language.ariaLabel": "Idioma",
|
||||||
|
|
||||||
"folderSelection.logoAlt": "Logo de CodeNomad",
|
"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.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.links.discord": "Discord de CodeNomad",
|
||||||
|
|
||||||
"folderSelection.empty.title": "No hay carpetas recientes",
|
"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.title": "Carpetas recientes",
|
||||||
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
||||||
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
|
"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.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
||||||
"folderSelection.browse.button": "Explorar carpetas",
|
"folderSelection.browse.button": "Buscar carpetas",
|
||||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||||
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
|
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
|
||||||
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
|
"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.navigate": "Navegar",
|
||||||
"folderSelection.hints.select": "Seleccionar",
|
"folderSelection.hints.select": "Seleccionar",
|
||||||
"folderSelection.hints.remove": "Quitar",
|
"folderSelection.hints.remove": "Eliminar",
|
||||||
"folderSelection.hints.browse": "Explorar",
|
"folderSelection.hints.browse": "Buscar",
|
||||||
|
|
||||||
"folderSelection.loading.title": "Iniciando instancia...",
|
"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.title": "Suelta una carpeta para abrirla",
|
||||||
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
"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.connecting": "Conectando...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
"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.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Guardado",
|
"settings.speech.save.saved": "Guardado",
|
||||||
"settings.speech.save.unsaved": "Cambios sin guardar",
|
"settings.speech.save.unsaved": "Cambios sin guardar",
|
||||||
"settings.speech.save.error": "Error al 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
|
} 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.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
||||||
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Fermer l'instance",
|
"commands.closeInstance.label": "Fermer l'onglet",
|
||||||
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle",
|
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
|
||||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer",
|
"commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Instance suivante",
|
"commands.nextInstance.label": "Onglet suivant",
|
||||||
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant",
|
"commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
|
||||||
"commands.nextInstance.keywords": "changer, naviguer, suivant",
|
"commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Instance précédente",
|
"commands.previousInstance.label": "Onglet précédent",
|
||||||
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent",
|
"commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
|
||||||
"commands.previousInstance.keywords": "changer, naviguer, précédent",
|
"commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
|
||||||
|
|
||||||
"commands.newSession.label": "Nouvelle session",
|
"commands.newSession.label": "Nouvelle session",
|
||||||
"commands.newSession.description": "Créer une nouvelle session parente",
|
"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.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
||||||
|
|
||||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
"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.links.discord": "Discord de CodeNomad",
|
||||||
|
|
||||||
"folderSelection.empty.title": "Aucun dossier récent",
|
"folderSelection.empty.title": "Aucun dossier récent",
|
||||||
@@ -16,13 +16,13 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
||||||
"folderSelection.recent.remove": "Retirer des récents",
|
"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.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
||||||
"folderSelection.browse.button": "Parcourir les dossiers",
|
"folderSelection.browse.button": "Parcourir les dossiers",
|
||||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
"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.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.advancedSettings": "Paramètres avancés",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
"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.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Enregistré",
|
"settings.speech.save.saved": "Enregistré",
|
||||||
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
||||||
"settings.speech.save.error": "Échec de l'enregistrement",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||||
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||||
|
|
||||||
"commands.closeInstance.label": "סגור מופע",
|
"commands.closeInstance.label": "סגור לשונית",
|
||||||
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
|
"commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
|
||||||
"commands.closeInstance.keywords": "עצור, סגור",
|
"commands.closeInstance.keywords": "עצור, סגור, לשונית",
|
||||||
|
|
||||||
"commands.nextInstance.label": "מופע הבא",
|
"commands.nextInstance.label": "הלשונית הבאה",
|
||||||
"commands.nextInstance.description": "עבור למופע הבא",
|
"commands.nextInstance.description": "עבור ללשונית העליונה הבאה",
|
||||||
"commands.nextInstance.keywords": "החלף, נווט",
|
"commands.nextInstance.keywords": "החלף, נווט, לשונית",
|
||||||
|
|
||||||
"commands.previousInstance.label": "מופע קודם",
|
"commands.previousInstance.label": "הלשונית הקודמת",
|
||||||
"commands.previousInstance.description": "עבור למופע הקודם",
|
"commands.previousInstance.description": "עבור ללשונית העליונה הקודמת",
|
||||||
"commands.previousInstance.keywords": "החלף, נווט",
|
"commands.previousInstance.keywords": "החלף, נווט, לשונית",
|
||||||
|
|
||||||
"commands.newSession.label": "סשן חדש",
|
"commands.newSession.label": "סשן חדש",
|
||||||
"commands.newSession.description": "צור סשן הורה חדש",
|
"commands.newSession.description": "צור סשן הורה חדש",
|
||||||
|
|||||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -193,4 +193,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "נשמר",
|
"settings.speech.save.saved": "נשמר",
|
||||||
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
|
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
|
||||||
"settings.speech.save.error": "השמירה נכשלה",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成",
|
"commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成",
|
||||||
"commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace",
|
"commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace",
|
||||||
|
|
||||||
"commands.closeInstance.label": "インスタンスを閉じる",
|
"commands.closeInstance.label": "タブを閉じる",
|
||||||
"commands.closeInstance.description": "現在のインスタンスのサーバーを停止",
|
"commands.closeInstance.description": "現在のトップレベルタブを閉じる",
|
||||||
"commands.closeInstance.keywords": "停止, 終了, 閉じる, stop, quit, close",
|
"commands.closeInstance.keywords": "閉じる, タブ, stop, quit, close",
|
||||||
|
|
||||||
"commands.nextInstance.label": "次のインスタンス",
|
"commands.nextInstance.label": "次のタブ",
|
||||||
"commands.nextInstance.description": "次のインスタンスタブへ切り替え",
|
"commands.nextInstance.description": "次のトップレベルタブへ切り替え",
|
||||||
"commands.nextInstance.keywords": "切り替え, 移動, switch, navigate",
|
"commands.nextInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
|
||||||
|
|
||||||
"commands.previousInstance.label": "前のインスタンス",
|
"commands.previousInstance.label": "前のタブ",
|
||||||
"commands.previousInstance.description": "前のインスタンスタブへ切り替え",
|
"commands.previousInstance.description": "前のトップレベルタブへ切り替え",
|
||||||
"commands.previousInstance.keywords": "切り替え, 移動, switch, navigate",
|
"commands.previousInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
|
||||||
|
|
||||||
"commands.newSession.label": "新しいセッション",
|
"commands.newSession.label": "新しいセッション",
|
||||||
"commands.newSession.description": "新しい親セッションを作成",
|
"commands.newSession.description": "新しい親セッションを作成",
|
||||||
|
|||||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "接続中...",
|
"folderSelection.servers.dialog.connecting": "接続中...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
||||||
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "保存済み",
|
"settings.speech.save.saved": "保存済み",
|
||||||
"settings.speech.save.unsaved": "未保存の変更",
|
"settings.speech.save.unsaved": "未保存の変更",
|
||||||
"settings.speech.save.error": "保存に失敗しました",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра",
|
"commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра",
|
||||||
"commands.newInstance.keywords": "папка, проект, рабочее пространство",
|
"commands.newInstance.keywords": "папка, проект, рабочее пространство",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Закрыть экземпляр",
|
"commands.closeInstance.label": "Закрыть вкладку",
|
||||||
"commands.closeInstance.description": "Остановить сервер текущего экземпляра",
|
"commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку",
|
||||||
"commands.closeInstance.keywords": "остановить, выйти, закрыть",
|
"commands.closeInstance.keywords": "остановить, выйти, закрыть, вкладка",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Следующий экземпляр",
|
"commands.nextInstance.label": "Следующая вкладка",
|
||||||
"commands.nextInstance.description": "Переключиться на следующую вкладку экземпляра",
|
"commands.nextInstance.description": "Переключиться на следующую верхнеуровневую вкладку",
|
||||||
"commands.nextInstance.keywords": "переключить, навигация",
|
"commands.nextInstance.keywords": "переключить, навигация, вкладка",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Предыдущий экземпляр",
|
"commands.previousInstance.label": "Предыдущая вкладка",
|
||||||
"commands.previousInstance.description": "Переключиться на предыдущую вкладку экземпляра",
|
"commands.previousInstance.description": "Переключиться на предыдущую верхнеуровневую вкладку",
|
||||||
"commands.previousInstance.keywords": "переключить, навигация",
|
"commands.previousInstance.keywords": "переключить, навигация, вкладка",
|
||||||
|
|
||||||
"commands.newSession.label": "Новая сессия",
|
"commands.newSession.label": "Новая сессия",
|
||||||
"commands.newSession.description": "Создать новую родительскую сессию",
|
"commands.newSession.description": "Создать новую родительскую сессию",
|
||||||
|
|||||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Подключение...",
|
"folderSelection.servers.dialog.connecting": "Подключение...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Сохранено",
|
"settings.speech.save.saved": "Сохранено",
|
||||||
"settings.speech.save.unsaved": "Есть несохранённые изменения",
|
"settings.speech.save.unsaved": "Есть несохранённые изменения",
|
||||||
"settings.speech.save.error": "Не удалось сохранить",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "打开文件夹选择器以创建新实例",
|
"commands.newInstance.description": "打开文件夹选择器以创建新实例",
|
||||||
"commands.newInstance.keywords": "folder, project, workspace, 文件夹, 项目, 工作区",
|
"commands.newInstance.keywords": "folder, project, workspace, 文件夹, 项目, 工作区",
|
||||||
|
|
||||||
"commands.closeInstance.label": "关闭实例",
|
"commands.closeInstance.label": "关闭标签页",
|
||||||
"commands.closeInstance.description": "停止当前实例的服务器",
|
"commands.closeInstance.description": "关闭当前顶层标签页",
|
||||||
"commands.closeInstance.keywords": "stop, quit, close, 停止, 退出, 关闭",
|
"commands.closeInstance.keywords": "stop, quit, close, 停止, 退出, 关闭, 标签",
|
||||||
|
|
||||||
"commands.nextInstance.label": "下一个实例",
|
"commands.nextInstance.label": "下一个标签页",
|
||||||
"commands.nextInstance.description": "切换到下一个实例标签页",
|
"commands.nextInstance.description": "切换到下一个顶层标签页",
|
||||||
"commands.nextInstance.keywords": "switch, navigate, 切换, 导航",
|
"commands.nextInstance.keywords": "switch, navigate, 切换, 导航, 标签",
|
||||||
|
|
||||||
"commands.previousInstance.label": "上一个实例",
|
"commands.previousInstance.label": "上一个标签页",
|
||||||
"commands.previousInstance.description": "切换到上一个实例标签页",
|
"commands.previousInstance.description": "切换到上一个顶层标签页",
|
||||||
"commands.previousInstance.keywords": "switch, navigate, 切换, 导航",
|
"commands.previousInstance.keywords": "switch, navigate, 切换, 导航, 标签",
|
||||||
|
|
||||||
"commands.newSession.label": "新建会话",
|
"commands.newSession.label": "新建会话",
|
||||||
"commands.newSession.description": "创建新的父会话",
|
"commands.newSession.description": "创建新的父会话",
|
||||||
|
|||||||
@@ -69,4 +69,5 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "连接中...",
|
"folderSelection.servers.dialog.connecting": "连接中...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
||||||
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -194,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "已保存",
|
"settings.speech.save.saved": "已保存",
|
||||||
"settings.speech.save.unsaved": "有未保存的更改",
|
"settings.speech.save.unsaved": "有未保存的更改",
|
||||||
"settings.speech.save.error": "保存失败",
|
"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
|
} 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 { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
|
||||||
import { keyboardRegistry } from "./keyboard-registry"
|
import { keyboardRegistry } from "./keyboard-registry"
|
||||||
import { isMac } from "./keyboard-utils"
|
import { isMac } from "./keyboard-utils"
|
||||||
|
|
||||||
export function setupTabKeyboardShortcuts(
|
export function setupTabKeyboardShortcuts(
|
||||||
handleNewInstance: () => void,
|
handleNewInstance: () => void,
|
||||||
handleCloseInstance: (instanceId: string) => void,
|
handleCloseActiveTab: () => Promise<void>,
|
||||||
handleNewSession: (instanceId: string) => void,
|
handleNewSession: (instanceId: string) => void,
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => void,
|
handleCloseSession: (instanceId: string, sessionId: string) => void,
|
||||||
handleCommandPalette: () => void,
|
handleCommandPalette: () => void,
|
||||||
@@ -35,11 +36,7 @@ export function setupTabKeyboardShortcuts(
|
|||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key >= "1" && e.key <= "9") {
|
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key >= "1" && e.key <= "9") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const index = parseInt(e.key) - 1
|
selectAppTabByIndex(parseInt(e.key) - 1)
|
||||||
const instanceIds = Array.from(instances().keys())
|
|
||||||
if (instanceIds[index]) {
|
|
||||||
setActiveInstanceId(instanceIds[index])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key >= "1" && e.key <= "9") {
|
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") {
|
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "w") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const instanceId = activeInstanceId()
|
void handleCloseActiveTab()
|
||||||
if (instanceId) {
|
|
||||||
handleCloseInstance(instanceId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "w") {
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "w") {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { keyboardRegistry } from "../keyboard-registry"
|
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"
|
import { activeSessionId, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||||
|
|
||||||
export function registerNavigationShortcuts() {
|
export function registerNavigationShortcuts() {
|
||||||
@@ -11,14 +12,8 @@ export function registerNavigationShortcuts() {
|
|||||||
id: "instance-prev",
|
id: "instance-prev",
|
||||||
key: "[",
|
key: "[",
|
||||||
modifiers: { ctrl: !isMac(), meta: isMac() },
|
modifiers: { ctrl: !isMac(), meta: isMac() },
|
||||||
handler: () => {
|
handler: () => selectPreviousAppTab(),
|
||||||
const ids = Array.from(instances().keys())
|
description: "previous tab",
|
||||||
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",
|
|
||||||
context: "global",
|
context: "global",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -26,14 +21,8 @@ export function registerNavigationShortcuts() {
|
|||||||
id: "instance-next",
|
id: "instance-next",
|
||||||
key: "]",
|
key: "]",
|
||||||
modifiers: { ctrl: !isMac(), meta: isMac() },
|
modifiers: { ctrl: !isMac(), meta: isMac() },
|
||||||
handler: () => {
|
handler: () => selectNextAppTab(),
|
||||||
const ids = Array.from(instances().keys())
|
description: "next tab",
|
||||||
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",
|
|
||||||
context: "global",
|
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 { getLogger } from "../lib/logger"
|
||||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||||
import { showWorkspaceLaunchError } from "./launch-errors"
|
import { showWorkspaceLaunchError } from "./launch-errors"
|
||||||
|
import { activeSidecarToken } from "./sidecars"
|
||||||
|
|
||||||
const log = getLogger("api")
|
const log = getLogger("api")
|
||||||
|
|
||||||
@@ -109,6 +110,8 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureActiveInstanceSelected(): void {
|
function ensureActiveInstanceSelected(): void {
|
||||||
|
if (activeSidecarToken()) return
|
||||||
|
|
||||||
const current = activeInstanceId()
|
const current = activeInstanceId()
|
||||||
const instanceMap = instances()
|
const instanceMap = instances()
|
||||||
if (current && instanceMap.has(current)) return
|
if (current && instanceMap.has(current)) return
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
|||||||
sessions: {},
|
sessions: {},
|
||||||
sessionOrder: [],
|
sessionOrder: [],
|
||||||
messages: {},
|
messages: {},
|
||||||
|
lastAssistantMessageIds: {},
|
||||||
messageInfoVersion: {},
|
messageInfoVersion: {},
|
||||||
pendingParts: {},
|
pendingParts: {},
|
||||||
sessionRevisions: {},
|
sessionRevisions: {},
|
||||||
@@ -218,6 +219,7 @@ export interface InstanceMessageStore {
|
|||||||
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
||||||
getSessionRevision: (sessionId: string) => number
|
getSessionRevision: (sessionId: string) => number
|
||||||
getSessionMessageIds: (sessionId: string) => string[]
|
getSessionMessageIds: (sessionId: string) => string[]
|
||||||
|
getLastAssistantMessageId: (sessionId: string) => string | undefined
|
||||||
// Index of the most recent message in the session that contains a compaction part.
|
// Index of the most recent message in the session that contains a compaction part.
|
||||||
// Returns -1 if there has been no compaction.
|
// Returns -1 if there has been no compaction.
|
||||||
getLastCompactionMessageIndex: (sessionId: string) => number
|
getLastCompactionMessageIndex: (sessionId: string) => number
|
||||||
@@ -234,6 +236,21 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
|
|
||||||
const messageInfoCache = new Map<string, MessageInfo>()
|
const messageInfoCache = new Map<string, MessageInfo>()
|
||||||
|
|
||||||
|
function findLastAssistantMessageId(messageIds: readonly string[]): string | undefined {
|
||||||
|
for (let index = messageIds.length - 1; index >= 0; index -= 1) {
|
||||||
|
const messageId = messageIds[index]
|
||||||
|
if (state.messages[messageId]?.role === "assistant") {
|
||||||
|
return messageId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeLastAssistantMessageId(sessionId: string, messageIds?: readonly string[]) {
|
||||||
|
if (!sessionId) return
|
||||||
|
setState("lastAssistantMessageIds", sessionId, findLastAssistantMessageId(messageIds ?? state.sessions[sessionId]?.messageIds ?? []))
|
||||||
|
}
|
||||||
|
|
||||||
function getLastCompactionMessageIndex(sessionId: string): number {
|
function getLastCompactionMessageIndex(sessionId: string): number {
|
||||||
if (!sessionId) return -1
|
if (!sessionId) return -1
|
||||||
const ids = state.sessions[sessionId]?.messageIds ?? []
|
const ids = state.sessions[sessionId]?.messageIds ?? []
|
||||||
@@ -306,6 +323,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return state.sessionRevisions[sessionId] ?? 0
|
return state.sessionRevisions[sessionId] ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLastAssistantMessageIdValue(sessionId: string) {
|
||||||
|
return state.lastAssistantMessageIds[sessionId]
|
||||||
|
}
|
||||||
|
|
||||||
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
|
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
|
||||||
setState("usage", sessionId, (current) => {
|
setState("usage", sessionId, (current) => {
|
||||||
const draft = current
|
const draft = current
|
||||||
@@ -375,6 +396,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
|
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
|
||||||
|
recomputeLastAssistantMessageId(input.id, nextMessageIds)
|
||||||
bumpSessionRevision(input.id)
|
bumpSessionRevision(input.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +467,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
messageIds: incomingIds,
|
messageIds: incomingIds,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}))
|
}))
|
||||||
|
recomputeLastAssistantMessageId(sessionId, incomingIds)
|
||||||
|
|
||||||
Object.values(normalizedRecords).forEach((record) => {
|
Object.values(normalizedRecords).forEach((record) => {
|
||||||
maybeUpdateLatestTodoFromRecord(record)
|
maybeUpdateLatestTodoFromRecord(record)
|
||||||
@@ -516,6 +539,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
|
|
||||||
insertMessageIntoSession(input.sessionId, input.id)
|
insertMessageIntoSession(input.sessionId, input.id)
|
||||||
flushPendingParts(input.id)
|
flushPendingParts(input.id)
|
||||||
|
recomputeLastAssistantMessageId(input.sessionId)
|
||||||
bumpSessionRevision(input.sessionId)
|
bumpSessionRevision(input.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,6 +754,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
if (state.latestTodos[sessionId]?.messageId === messageId) {
|
if (state.latestTodos[sessionId]?.messageId === messageId) {
|
||||||
clearLatestTodoSnapshot(sessionId)
|
clearLatestTodoSnapshot(sessionId)
|
||||||
}
|
}
|
||||||
|
recomputeLastAssistantMessageId(sessionId)
|
||||||
bumpSessionRevision(sessionId)
|
bumpSessionRevision(sessionId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -816,7 +841,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
affectedSessions.add(session.id)
|
affectedSessions.add(session.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId))
|
affectedSessions.forEach((sessionId) => {
|
||||||
|
recomputeLastAssistantMessageId(sessionId)
|
||||||
|
bumpSessionRevision(sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
const infoEntry = messageInfoCache.get(options.oldId)
|
const infoEntry = messageInfoCache.get(options.oldId)
|
||||||
if (infoEntry) {
|
if (infoEntry) {
|
||||||
@@ -1037,6 +1065,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recomputeLastAssistantMessageId(sessionId, keptIds)
|
||||||
bumpSessionRevision(sessionId)
|
bumpSessionRevision(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1157,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setState("lastAssistantMessageIds", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[sessionId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setState("scrollState", (prev) => {
|
setState("scrollState", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
const prefix = `${sessionId}:`
|
const prefix = `${sessionId}:`
|
||||||
@@ -1190,16 +1225,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
|
|
||||||
setSessionRevert,
|
setSessionRevert,
|
||||||
getSessionRevert,
|
getSessionRevert,
|
||||||
rebuildUsage,
|
rebuildUsage,
|
||||||
getSessionUsage,
|
getSessionUsage,
|
||||||
setScrollSnapshot,
|
setScrollSnapshot,
|
||||||
getScrollSnapshot,
|
getScrollSnapshot,
|
||||||
getSessionRevision: getSessionRevisionValue,
|
getSessionRevision: getSessionRevisionValue,
|
||||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||||
getLastCompactionMessageIndex,
|
getLastAssistantMessageId: getLastAssistantMessageIdValue,
|
||||||
getMessage: (messageId: string) => state.messages[messageId],
|
getLastCompactionMessageIndex,
|
||||||
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
getMessage: (messageId: string) => state.messages[messageId],
|
||||||
clearSession,
|
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
||||||
clearInstance,
|
clearSession,
|
||||||
}
|
clearInstance,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface InstanceMessageState {
|
|||||||
sessions: Record<string, SessionRecord>
|
sessions: Record<string, SessionRecord>
|
||||||
sessionOrder: string[]
|
sessionOrder: string[]
|
||||||
messages: Record<string, MessageRecord>
|
messages: Record<string, MessageRecord>
|
||||||
|
lastAssistantMessageIds: Record<string, string | undefined>
|
||||||
messageInfoVersion: Record<string, number>
|
messageInfoVersion: Record<string, number>
|
||||||
pendingParts: Record<string, PendingPartEntry[]>
|
pendingParts: Record<string, PendingPartEntry[]>
|
||||||
sessionRevisions: Record<string, number>
|
sessionRevisions: Record<string, number>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSignal } from "solid-js"
|
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 [settingsOpen, setSettingsOpen] = createSignal(false)
|
||||||
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
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);
|
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 tabs */
|
||||||
.session-tab-base {
|
.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;
|
@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