Compare commits
15 Commits
v0.11.1-de
...
v0.11.2-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
127a1f628d | ||
|
|
859312ba3b | ||
|
|
4eaa711f01 | ||
|
|
c8ff858565 | ||
|
|
6de6ef5a4a | ||
|
|
4dee154490 | ||
|
|
ef388adc4f | ||
|
|
e8cfad1266 | ||
|
|
3f82dd21fe | ||
|
|
dc13d9a7d0 | ||
|
|
29557fba6d | ||
|
|
dea5079713 | ||
|
|
ddc58a2c3c | ||
|
|
eafd4d83af | ||
|
|
1a0734c6b1 |
@@ -123,3 +123,6 @@ To build the Desktop App from source:
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -2809,9 +2809,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
||||
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -11985,7 +11985,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12021,7 +12021,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12062,7 +12062,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12070,12 +12070,12 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.4"
|
||||
"@opencode-ai/plugin": "1.2.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
||||
|
||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||
|
||||
// Special-case OpenCode directory override.
|
||||
//
|
||||
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
||||
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
||||
// injecting per-request headers, we encode an override into the *path* and strip it
|
||||
// before proxying to the instance.
|
||||
//
|
||||
// Example proxied request path:
|
||||
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
||||
//
|
||||
// The server will decode <base64url> -> absolute directory, validate it, then set
|
||||
// x-opencode-directory accordingly and forward the request to /session/create.
|
||||
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
||||
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
@@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
|
||||
return
|
||||
}
|
||||
|
||||
const directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||
try {
|
||||
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
let directory: string | null = null
|
||||
let forwardedSuffix = extracted.forwardedSuffix
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
if (extracted.overrideDirectory) {
|
||||
try {
|
||||
directory = validateAndNormalizeOverrideDirectory({
|
||||
overrideDirectory: extracted.overrideDirectory,
|
||||
workspaceRoot: workspace.path,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
@@ -533,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
|
||||
})
|
||||
}
|
||||
|
||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||
overrideDirectory: string | null
|
||||
forwardedSuffix: string | undefined
|
||||
} {
|
||||
if (!pathSuffix) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
// Fastify wildcard param does not include a leading slash.
|
||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
||||
const slashIndex = rest.indexOf("/")
|
||||
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
||||
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
||||
|
||||
if (!encoded) {
|
||||
throw new Error("Missing directory override")
|
||||
}
|
||||
|
||||
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
||||
throw new Error("Directory override too large")
|
||||
}
|
||||
|
||||
let overrideDirectory = ""
|
||||
try {
|
||||
overrideDirectory = decodeBase64Url(encoded)
|
||||
} catch {
|
||||
throw new Error("Invalid directory override")
|
||||
}
|
||||
const forwardedSuffix = remaining
|
||||
return { overrideDirectory, forwardedSuffix }
|
||||
}
|
||||
|
||||
function decodeBase64Url(input: string): string {
|
||||
// base64url -> base64
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
||||
const base64 = `${normalized}${padding}`
|
||||
return Buffer.from(base64, "base64").toString("utf-8")
|
||||
}
|
||||
|
||||
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
||||
const raw = params.overrideDirectory.trim()
|
||||
if (!raw) {
|
||||
throw new Error("Override directory is empty")
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(raw)) {
|
||||
throw new Error("Override directory must be an absolute path")
|
||||
}
|
||||
|
||||
if (!fs.existsSync(raw)) {
|
||||
throw new Error(`Override directory does not exist: ${raw}`)
|
||||
}
|
||||
|
||||
const stats = fs.statSync(raw)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Override path is not a directory: ${raw}`)
|
||||
}
|
||||
|
||||
const normalizedOverride = fs.realpathSync(raw)
|
||||
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
||||
|
||||
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
||||
throw new Error("Override directory must be within the workspace root")
|
||||
}
|
||||
|
||||
return normalizedOverride
|
||||
}
|
||||
|
||||
function isSubpath(candidate: string, root: string): boolean {
|
||||
const rel = path.relative(root, candidate)
|
||||
if (rel === "") return true
|
||||
if (rel === "..") return false
|
||||
if (rel.startsWith(`..${path.sep}`)) return false
|
||||
if (path.isAbsolute(rel)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
|
||||
@@ -119,7 +119,8 @@
|
||||
showError(message || `Login failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
window.location.href = "/"
|
||||
// Replace history entry so Back doesn't return to /login.
|
||||
window.location.replace("/")
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
|
||||
@@ -51,7 +51,19 @@ function getTokenHtml(): string {
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/login", async (_request, reply) => {
|
||||
app.get("/login", async (request, reply) => {
|
||||
// If already authenticated, don't show the login page.
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (session) {
|
||||
reply.redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid caching the login page (helps with bfcache/back behavior).
|
||||
reply.header("Cache-Control", "no-store")
|
||||
reply.header("Pragma", "no-cache")
|
||||
reply.header("Expires", "0")
|
||||
|
||||
const status = deps.authManager.getStatus()
|
||||
reply.type("text/html").send(getLoginHtml(status.username))
|
||||
})
|
||||
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid caching the token bootstrap page.
|
||||
reply.header("Cache-Control", "no-store")
|
||||
reply.header("Pragma", "no-cache")
|
||||
reply.header("Expires", "0")
|
||||
|
||||
reply.type("text/html").send(getTokenHtml())
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import { Minimize2 } from "lucide-solid"
|
||||
import AlertDialog from "./components/alert-dialog"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import { showConfirmDialog } from "./stores/alerts"
|
||||
@@ -82,6 +84,46 @@ const App: Component = () => {
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||
|
||||
// In-memory only: hides chrome on phone; may also request browser fullscreen.
|
||||
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
|
||||
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
|
||||
|
||||
const fullscreenSupported = () => {
|
||||
if (typeof document === "undefined") return false
|
||||
const el = document.documentElement as any
|
||||
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
|
||||
}
|
||||
|
||||
const syncBrowserFullscreenState = () => {
|
||||
if (typeof document === "undefined") return
|
||||
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
|
||||
}
|
||||
|
||||
const enterMobileFullscreen = async () => {
|
||||
if (!isPhoneLayout()) return
|
||||
setMobileFullscreenMode(true)
|
||||
if (!fullscreenSupported()) return
|
||||
try {
|
||||
await document.documentElement.requestFullscreen()
|
||||
} catch {
|
||||
// Ignore: immersive mode still works without browser fullscreen.
|
||||
}
|
||||
}
|
||||
|
||||
const exitMobileFullscreen = async () => {
|
||||
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
|
||||
try {
|
||||
await document.exitFullscreen()
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
setMobileFullscreenMode(false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
@@ -95,6 +137,56 @@ const App: Component = () => {
|
||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof document === "undefined") return
|
||||
syncBrowserFullscreenState()
|
||||
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
|
||||
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
|
||||
const updateKeyboardOffset = () => {
|
||||
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
|
||||
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
|
||||
}
|
||||
|
||||
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
|
||||
schedule()
|
||||
vv.addEventListener("resize", schedule)
|
||||
vv.addEventListener("scroll", schedule)
|
||||
window.addEventListener("orientationchange", schedule)
|
||||
|
||||
onCleanup(() => {
|
||||
vv.removeEventListener("resize", schedule)
|
||||
vv.removeEventListener("scroll", schedule)
|
||||
window.removeEventListener("orientationchange", schedule)
|
||||
document.documentElement.style.removeProperty("--keyboard-offset")
|
||||
})
|
||||
})
|
||||
|
||||
// If the user exits browser fullscreen via browser UI, restore chrome.
|
||||
let lastBrowserFullscreen = false
|
||||
createEffect(() => {
|
||||
const active = browserFullscreenActive()
|
||||
const mode = mobileFullscreenMode()
|
||||
if (mode && lastBrowserFullscreen && !active) {
|
||||
setMobileFullscreenMode(false)
|
||||
}
|
||||
lastBrowserFullscreen = active
|
||||
})
|
||||
|
||||
// If we leave phone layout (rotation / resize), restore chrome.
|
||||
createEffect(() => {
|
||||
if (!isPhoneLayout() && mobileFullscreenMode()) {
|
||||
void exitMobileFullscreen()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
@@ -405,19 +497,34 @@ const App: Component = () => {
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
<div class="h-screen w-screen flex flex-col">
|
||||
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
|
||||
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
|
||||
<div class="mobile-fullscreen-exit-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button mobile-fullscreen-exit-button"
|
||||
onClick={() => void exitMobileFullscreen()}
|
||||
aria-label={t("instanceShell.fullscreen.exit")}
|
||||
title={t("instanceShell.fullscreen.exit")}
|
||||
>
|
||||
<Minimize2 class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
fallback={
|
||||
<>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
@@ -435,7 +542,10 @@ const App: Component = () => {
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={instanceTabBarHeight()}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
|
||||
@@ -115,28 +115,36 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
<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-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content
|
||||
class="modal-surface w-full max-w-xl md:max-w-2xl p-6 border border-base shadow-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div class="flex items-start gap-3 min-h-0">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 min-h-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||
<div
|
||||
class="max-h-[60vh] overflow-auto pr-2 whitespace-pre-wrap break-words"
|
||||
style={{ "overflow-wrap": "anywhere" }}
|
||||
>
|
||||
{payload.message}
|
||||
{payload.detail && <div class="mt-3">{payload.detail}</div>}
|
||||
</div>
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
@@ -185,14 +193,14 @@ const AlertDialog: Component = () => {
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertDialog
|
||||
|
||||
123
packages/ui/src/components/context-meter.tsx
Normal file
123
packages/ui/src/components/context-meter.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
interface ContextMeterProps {
|
||||
usedTokens: number
|
||||
availableTokens: number | null
|
||||
formatTokens: (value: number) => string
|
||||
usedLabel: string
|
||||
availableLabel: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function resolveFillColor(percent: number): string {
|
||||
if (percent >= 0.8) return "var(--status-error)"
|
||||
if (percent >= 0.6) return "var(--status-warning)"
|
||||
return "var(--status-success)"
|
||||
}
|
||||
|
||||
export const ContextMeter: Component<ContextMeterProps> = (props) => {
|
||||
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
|
||||
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
|
||||
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
|
||||
|
||||
const percent = () => {
|
||||
const usedValue = used()
|
||||
const availableValue = available()
|
||||
if (availableValue === null || availableValue <= 0) return null
|
||||
|
||||
// Heuristic: if available >= used, treat it like a capacity/limit.
|
||||
// Otherwise treat it like remaining tokens.
|
||||
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
|
||||
return clamp(ratio, 0, 1)
|
||||
}
|
||||
|
||||
const fillColor = () => {
|
||||
const value = percent()
|
||||
if (value === null) return "var(--border-base)"
|
||||
return resolveFillColor(value)
|
||||
}
|
||||
|
||||
const percentLabel = () => {
|
||||
const value = percent()
|
||||
if (value === null) return "--"
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
|
||||
const containerClass =
|
||||
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
|
||||
|
||||
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
||||
const rad = (angleDeg * Math.PI) / 180
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad),
|
||||
}
|
||||
}
|
||||
|
||||
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
||||
const start = polarToCartesian(cx, cy, r, startAngle)
|
||||
const end = polarToCartesian(cx, cy, r, endAngle)
|
||||
const delta = ((endAngle - startAngle) % 360 + 360) % 360
|
||||
const largeArc = delta > 180 ? 1 : 0
|
||||
|
||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||
}
|
||||
|
||||
const circle = () => {
|
||||
const value = percent()
|
||||
const size = 22
|
||||
const r = 9
|
||||
const cx = 11
|
||||
const cy = 11
|
||||
const progress = value === null ? 0 : value
|
||||
const startAngle = -90
|
||||
const endAngle = startAngle + progress * 360
|
||||
const isFull = progress >= 0.999
|
||||
const hasFill = progress > 0.001
|
||||
|
||||
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 22 22"
|
||||
aria-hidden="true"
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
|
||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
|
||||
{isFull ? (
|
||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
|
||||
) : sectorPath ? (
|
||||
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
|
||||
) : null}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipText = () => `Context Used: ${percentLabel()}`
|
||||
|
||||
return (
|
||||
<div class="inline-flex items-center gap-2" title={tooltipText()}>
|
||||
{circle()}
|
||||
<div class={containerClass}>
|
||||
<span class={LABEL_CLASS}>{props.usedLabel}</span>
|
||||
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
|
||||
<span class="text-muted">/</span>
|
||||
<span class={LABEL_CLASS}>{props.availableLabel}</span>
|
||||
<span class="font-semibold text-primary tabular-nums">
|
||||
{available() !== null ? props.formatTokens(available() as number) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextMeter
|
||||
@@ -12,6 +12,7 @@ interface MonacoDiffViewerProps {
|
||||
after: string
|
||||
viewMode?: "split" | "unified"
|
||||
contextMode?: "expanded" | "collapsed"
|
||||
wordWrap?: "on" | "off"
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
@@ -54,7 +55,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: "selection",
|
||||
fontSize: 13,
|
||||
wordWrap: "off",
|
||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||
@@ -81,6 +82,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: viewMode === "split",
|
||||
@@ -89,7 +91,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
contextMode === "collapsed"
|
||||
? { enabled: true }
|
||||
: { enabled: false },
|
||||
wordWrap,
|
||||
})
|
||||
|
||||
try {
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { disposeInstance } from "../stores/instances"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
showDisposeButton?: boolean
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||
|
||||
const [isDisposing, setIsDisposing] = createSignal(false)
|
||||
|
||||
const currentInstance = () => instanceAccessor()
|
||||
const metadata = () => metadataAccessor()
|
||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
return env ? Object.entries(env) : []
|
||||
})
|
||||
|
||||
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
|
||||
|
||||
const handleDisposeInstance = async () => {
|
||||
if (!disposeEnabled()) return
|
||||
|
||||
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
|
||||
title: t("infoView.dispose.confirm.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setIsDisposing(true)
|
||||
try {
|
||||
const ok = await disposeInstance(currentInstance().id)
|
||||
if (ok) {
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.success"),
|
||||
variant: "success",
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.error"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to dispose instance", error)
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.error"),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setIsDisposing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.showDisposeButton}>
|
||||
<div class="pt-3 border-t border-base">
|
||||
<button
|
||||
type="button"
|
||||
class="button-danger button-small w-full"
|
||||
onClick={handleDisposeInstance}
|
||||
disabled={!disposeEnabled()}
|
||||
>
|
||||
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner"
|
||||
import PermissionApprovalModal from "../permission-approval-modal"
|
||||
import SessionView from "../session/session-view"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
import ContextMeter from "../context-meter"
|
||||
import { sseManager } from "../../lib/sse-manager"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
@@ -41,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||
import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getSessionStatus } from "../../stores/session-status"
|
||||
import { ShieldAlert } from "lucide-solid"
|
||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
import {
|
||||
@@ -69,6 +70,11 @@ interface InstanceShellProps {
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
tabBarOffset: number
|
||||
|
||||
// In-memory only: mobile immersive/fullscreen mode.
|
||||
mobileFullscreenMode: boolean
|
||||
onEnterMobileFullscreen: () => void
|
||||
onExitMobileFullscreen: () => void
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
@@ -117,6 +123,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
||||
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
||||
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
||||
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||
|
||||
@@ -349,16 +357,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
measureDrawerHost,
|
||||
})
|
||||
|
||||
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
|
||||
|
||||
|
||||
const formattedAvailableTokens = () => {
|
||||
const avail = tokenStats().avail
|
||||
if (typeof avail === "number") {
|
||||
return formatTokenTotal(avail)
|
||||
}
|
||||
return "--"
|
||||
}
|
||||
|
||||
const renderLeftPanel = () => {
|
||||
if (leftPinned()) {
|
||||
@@ -594,13 +592,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
{renderLeftPanel()}
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={!mobileFullscreen()}>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={leftDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
@@ -647,6 +646,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={!props.mobileFullscreenMode}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={props.onEnterMobileFullscreen}
|
||||
aria-label={t("instanceShell.fullscreen.enter")}
|
||||
title={t("instanceShell.fullscreen.enter")}
|
||||
size="small"
|
||||
>
|
||||
<Maximize2 class="w-5 h-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
|
||||
<Show when={rightDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
@@ -661,20 +672,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<ContextMeter
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
formatTokens={formatTokenTotal}
|
||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||
/>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -693,18 +699,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<Show when={!showingInfoView()}>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
<ContextMeter
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
formatTokens={formatTokenTotal}
|
||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="ml-auto flex items-center session-header-hints">
|
||||
@@ -769,9 +770,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Show>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
@@ -808,6 +810,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isPhoneLayout={isPhoneLayout()}
|
||||
compactPromptLayout={compactPromptLayout()}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||
import type { Session } from "../../../../types/session"
|
||||
import type { DrawerViewState } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||
|
||||
import ChangesTab from "./tabs/ChangesTab"
|
||||
import FilesTab from "./tabs/FilesTab"
|
||||
@@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import {
|
||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
|
||||
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||
@@ -102,6 +103,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
||||
)
|
||||
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
|
||||
)
|
||||
|
||||
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||
@@ -195,6 +199,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
|
||||
})
|
||||
|
||||
const clampSplitWidth = (value: number) => {
|
||||
const min = 200
|
||||
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||
@@ -738,8 +747,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
@@ -765,8 +776,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
|
||||
@@ -1,50 +1,61 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
interface DiffToolbarProps {
|
||||
viewMode: DiffViewMode
|
||||
contextMode: DiffContextMode
|
||||
wordWrapMode: DiffWordWrapMode
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
}
|
||||
|
||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
||||
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
||||
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
||||
|
||||
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
|
||||
const contextModeTitle = () =>
|
||||
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
|
||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
|
||||
|
||||
return (
|
||||
<div class="file-viewer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "split"}
|
||||
onClick={() => props.onViewModeChange("split")}
|
||||
class="file-viewer-toolbar-icon-button"
|
||||
onClick={() => props.onViewModeChange(nextViewMode())}
|
||||
aria-label={viewModeTitle()}
|
||||
title={viewModeTitle()}
|
||||
>
|
||||
Split
|
||||
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "unified"}
|
||||
onClick={() => props.onViewModeChange("unified")}
|
||||
class="file-viewer-toolbar-icon-button"
|
||||
onClick={() => props.onContextModeChange(nextContextMode())}
|
||||
aria-label={contextModeTitle()}
|
||||
title={contextModeTitle()}
|
||||
>
|
||||
Unified
|
||||
{nextContextMode() === "collapsed" ? (
|
||||
<FoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
||||
aria-pressed={props.contextMode === "collapsed"}
|
||||
onClick={() => props.onContextModeChange("collapsed")}
|
||||
title="Hide unchanged regions"
|
||||
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
|
||||
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
|
||||
aria-label={wordWrapTitle()}
|
||||
title={wordWrapTitle()}
|
||||
>
|
||||
Collapsed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
|
||||
aria-pressed={props.contextMode === "expanded"}
|
||||
onClick={() => props.onContextModeChange("expanded")}
|
||||
title="Show full file"
|
||||
>
|
||||
Expanded
|
||||
<WrapText class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
interface ChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -18,8 +18,10 @@ interface ChangesTabProps {
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
@@ -77,14 +79,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
||||
@@ -102,6 +96,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -182,6 +177,17 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ "margin-left": "auto" }}>
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrapMode={props.diffWordWrapMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -29,8 +29,10 @@ interface GitChangesTabProps {
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
|
||||
onOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
@@ -80,14 +82,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={props.selectedLoading()}
|
||||
@@ -122,6 +116,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -237,6 +232,15 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrapMode={props.diffWordWrapMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
|
||||
@@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
|
||||
export type DiffContextMode = "expanded" | "collapsed"
|
||||
|
||||
export type DiffWordWrapMode = "on" | "off"
|
||||
|
||||
@@ -23,6 +23,7 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-
|
||||
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
||||
|
||||
export const clampWidth = (value: number) =>
|
||||
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Show } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import ContextMeter from "./context-meter"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
|
||||
@@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||
|
||||
return (
|
||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
|
||||
<div class="connection-status-text connection-status-info">
|
||||
<div class="connection-status-usage">
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
</div>
|
||||
<ContextMeter
|
||||
usedTokens={props.usedTokens}
|
||||
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
|
||||
formatTokens={props.formatTokens}
|
||||
usedLabel={t("messageListHeader.metrics.usedLabel")}
|
||||
availableLabel={t("messageListHeader.metrics.availableLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import ToolCall from "./tool-call"
|
||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||
import { Markdown } from "./markdown"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
@@ -17,16 +16,18 @@ interface MessagePartProps {
|
||||
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
||||
primaryUserTextPartId?: string | null
|
||||
onRendered?: () => void
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
}
|
||||
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
const partType = () => props.part?.type || ""
|
||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||
const isAssistantMessage = () => props.messageType === "assistant"
|
||||
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
||||
const markdownContainerClass = () => "message-text message-text-assistant"
|
||||
const textContainerRole = () => props.messageType || "assistant"
|
||||
|
||||
const shouldHideTextPart = () => {
|
||||
const part = props.part
|
||||
@@ -57,6 +58,11 @@ interface MessagePartProps {
|
||||
return ""
|
||||
}
|
||||
|
||||
const canRenderMarkdown = () => {
|
||||
const id = (props.part as unknown as { id?: unknown })?.id
|
||||
return typeof id === "string" && id.length > 0
|
||||
}
|
||||
|
||||
function reasoningSegmentHasText(segment: unknown): boolean {
|
||||
if (typeof segment === "string") {
|
||||
return segment.trim().length > 0
|
||||
@@ -91,20 +97,28 @@ interface MessagePartProps {
|
||||
|
||||
const createTextPartForMarkdown = (): TextPart => {
|
||||
const part = props.part
|
||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
// Pass through the original part so `renderCache` updates persist.
|
||||
return part as unknown as TextPart
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && typeof (part as any).text === "string") {
|
||||
// Reasoning parts render as markdown in some views; normalize to TextPart.
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
synthetic: part.type === "text" ? part.synthetic : false,
|
||||
version: (part as { version?: number }).version
|
||||
text: (part as any).text,
|
||||
synthetic: false,
|
||||
version: (part as { version?: number }).version,
|
||||
renderCache: (part as any).renderCache,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
type: "text",
|
||||
text: "",
|
||||
synthetic: false
|
||||
synthetic: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,22 +131,18 @@ interface MessagePartProps {
|
||||
<Switch>
|
||||
<Match when={partType() === "text"}>
|
||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||
<div class={textContainerClass()}>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span class="text-primary">{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
|
||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
|
||||
@@ -176,15 +176,26 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
const isCoarsePointer = () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// Scope global "type-to-focus" behavior to the active, visible prompt only.
|
||||
if (typeof document === "undefined") return
|
||||
if (isCoarsePointer()) return
|
||||
if (props.isActive === false) return
|
||||
if (props.disabled) return
|
||||
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
|
||||
const isInputElement =
|
||||
activeElement?.tagName === "INPUT" ||
|
||||
activeElement?.tagName === "TEXTAREA" ||
|
||||
activeElement?.tagName === "SELECT" ||
|
||||
activeElement?.isContentEditable
|
||||
Boolean(activeElement?.isContentEditable)
|
||||
|
||||
if (isInputElement) return
|
||||
|
||||
@@ -192,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
if (isModifierKey) return
|
||||
|
||||
const isSpecialKey =
|
||||
e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
|
||||
e.key === "Tab" ||
|
||||
e.key === "Enter" ||
|
||||
e.key.startsWith("Arrow") ||
|
||||
e.key === "Backspace" ||
|
||||
e.key === "Delete"
|
||||
if (isSpecialKey) return
|
||||
|
||||
if (e.key.length === 1 && textareaRef && !props.disabled) {
|
||||
textareaRef.focus()
|
||||
const textarea = textareaRef
|
||||
if (!textarea || textarea.disabled) return
|
||||
|
||||
// In session cache mode inactive panes are display:none; avoid stealing focus.
|
||||
if (textarea.offsetParent === null) return
|
||||
|
||||
if (e.key.length === 1) {
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleGlobalKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleGlobalKeyDown)
|
||||
})
|
||||
@@ -435,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={expandState() === "expanded" ? 15 : 4}
|
||||
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autoCapitalize="off"
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface PromptInputProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
sessionId: string
|
||||
|
||||
// Used to scope global "type-to-focus" behavior.
|
||||
isActive?: boolean
|
||||
|
||||
// Phone/tablet layouts should keep the expanded prompt more compact.
|
||||
compactLayout?: boolean
|
||||
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||
onRunShell?: (command: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
|
||||
@@ -23,6 +23,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
|
||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
@@ -88,6 +89,10 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (applyingListeningMode()) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||
variant: "warning",
|
||||
@@ -100,12 +105,21 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
return
|
||||
}
|
||||
|
||||
setListeningMode(targetMode)
|
||||
const restarted = await restartCli()
|
||||
if (!restarted) {
|
||||
setError(t("remoteAccess.restart.errorManual"))
|
||||
} else {
|
||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||
setApplyingListeningMode(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Important: await the config patch before restart so Electron reads the updated mode from disk.
|
||||
await setListeningMode(targetMode)
|
||||
const restarted = await restartCli()
|
||||
if (!restarted) {
|
||||
setError(t("remoteAccess.restart.errorManual"))
|
||||
} else {
|
||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplyingListeningMode(false)
|
||||
}
|
||||
|
||||
void refreshMeta()
|
||||
@@ -196,6 +210,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
onChange={(nextChecked) => {
|
||||
void handleAllowConnectionsChange(nextChecked)
|
||||
}}
|
||||
disabled={loading() || applyingListeningMode()}
|
||||
>
|
||||
<Switch.Input />
|
||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||
|
||||
@@ -28,6 +28,8 @@ interface SessionViewProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
escapeInDebounce: boolean
|
||||
isPhoneLayout?: boolean
|
||||
compactPromptLayout?: boolean
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
@@ -76,6 +78,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
(isActive) => {
|
||||
if (!isActive) return
|
||||
|
||||
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
|
||||
if (props.isPhoneLayout) return
|
||||
|
||||
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
|
||||
if (typeof document === "undefined") return
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
@@ -314,17 +319,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerPromptInputApi={registerPromptInputApi}
|
||||
/>
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
isActive={props.isActive}
|
||||
compactLayout={props.compactPromptLayout}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerPromptInputApi={registerPromptInputApi}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
|
||||
"instanceShell.rightDrawer.toggle.close": "Close right drawer",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Full screen",
|
||||
"instanceShell.fullscreen.exit": "Exit full screen",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Used",
|
||||
"instanceShell.metrics.availableLabel": "Avail",
|
||||
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
|
||||
"infoView.logs.empty.waiting": "Waiting for server output...",
|
||||
"infoView.logs.scrollToBottom": "Scroll to bottom",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Dispose instance",
|
||||
"infoView.dispose.actions.disposing": "Disposing...",
|
||||
"infoView.dispose.confirm.title": "Dispose instance?",
|
||||
"infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Dispose",
|
||||
"infoView.dispose.confirm.cancelLabel": "Cancel",
|
||||
"infoView.dispose.toast.success": "Instance disposed. Reloading...",
|
||||
"infoView.dispose.toast.error": "Failed to dispose instance.",
|
||||
} as const
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
|
||||
"instanceShell.rightDrawer.toggle.close": "Cerrar panel derecho",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Pantalla completa",
|
||||
"instanceShell.fullscreen.exit": "Salir de pantalla completa",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Usado",
|
||||
"instanceShell.metrics.availableLabel": "Disp.",
|
||||
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.",
|
||||
"infoView.logs.empty.waiting": "Esperando la salida del servidor...",
|
||||
"infoView.logs.scrollToBottom": "Desplazarse al final",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Desechar instancia",
|
||||
"infoView.dispose.actions.disposing": "Desechando...",
|
||||
"infoView.dispose.confirm.title": "¿Desechar instancia?",
|
||||
"infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Desechar",
|
||||
"infoView.dispose.confirm.cancelLabel": "Cancelar",
|
||||
"infoView.dispose.toast.success": "Instancia desechada. Recargando...",
|
||||
"infoView.dispose.toast.error": "No se pudo desechar la instancia.",
|
||||
} as const
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
|
||||
"instanceShell.rightDrawer.toggle.close": "Fermer le tiroir droit",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Plein écran",
|
||||
"instanceShell.fullscreen.exit": "Quitter le plein écran",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Utilisé",
|
||||
"instanceShell.metrics.availableLabel": "Dispo",
|
||||
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.",
|
||||
"infoView.logs.empty.waiting": "En attente de la sortie du serveur...",
|
||||
"infoView.logs.scrollToBottom": "Aller en bas",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Réinitialiser l'instance",
|
||||
"infoView.dispose.actions.disposing": "Réinitialisation...",
|
||||
"infoView.dispose.confirm.title": "Réinitialiser l'instance ?",
|
||||
"infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Réinitialiser",
|
||||
"infoView.dispose.confirm.cancelLabel": "Annuler",
|
||||
"infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...",
|
||||
"infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.",
|
||||
} as const
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "右ドロワーを開く",
|
||||
"instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる",
|
||||
|
||||
"instanceShell.fullscreen.enter": "全画面",
|
||||
"instanceShell.fullscreen.exit": "全画面を終了",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "使用",
|
||||
"instanceShell.metrics.availableLabel": "残り",
|
||||
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。",
|
||||
"infoView.logs.empty.waiting": "サーバー出力を待機中...",
|
||||
"infoView.logs.scrollToBottom": "最下部へスクロール",
|
||||
|
||||
"infoView.dispose.actions.dispose": "インスタンスを破棄",
|
||||
"infoView.dispose.actions.disposing": "破棄しています...",
|
||||
"infoView.dispose.confirm.title": "インスタンスを破棄しますか?",
|
||||
"infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。",
|
||||
"infoView.dispose.confirm.confirmLabel": "破棄",
|
||||
"infoView.dispose.confirm.cancelLabel": "キャンセル",
|
||||
"infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...",
|
||||
"infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。",
|
||||
} as const
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Открыть правую панель",
|
||||
"instanceShell.rightDrawer.toggle.close": "Закрыть правую панель",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Полный экран",
|
||||
"instanceShell.fullscreen.exit": "Выйти из полного экрана",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Использовано",
|
||||
"instanceShell.metrics.availableLabel": "Доступно",
|
||||
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.",
|
||||
"infoView.logs.empty.waiting": "Ожидание вывода сервера…",
|
||||
"infoView.logs.scrollToBottom": "Прокрутить вниз",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Сбросить инстанс",
|
||||
"infoView.dispose.actions.disposing": "Сброс...",
|
||||
"infoView.dispose.confirm.title": "Сбросить инстанс?",
|
||||
"infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Сбросить",
|
||||
"infoView.dispose.confirm.cancelLabel": "Отмена",
|
||||
"infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...",
|
||||
"infoView.dispose.toast.error": "Не удалось сбросить инстанс.",
|
||||
} as const
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "打开右侧抽屉",
|
||||
"instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉",
|
||||
|
||||
"instanceShell.fullscreen.enter": "全屏",
|
||||
"instanceShell.fullscreen.exit": "退出全屏",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "已用",
|
||||
"instanceShell.metrics.availableLabel": "可用",
|
||||
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。",
|
||||
"infoView.logs.empty.waiting": "正在等待服务器输出...",
|
||||
"infoView.logs.scrollToBottom": "滚动到底部",
|
||||
|
||||
"infoView.dispose.actions.dispose": "释放实例",
|
||||
"infoView.dispose.actions.disposing": "正在释放...",
|
||||
"infoView.dispose.confirm.title": "要释放实例吗?",
|
||||
"infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。",
|
||||
"infoView.dispose.confirm.confirmLabel": "释放",
|
||||
"infoView.dispose.confirm.cancelLabel": "取消",
|
||||
"infoView.dispose.toast.success": "实例已释放。正在重新加载...",
|
||||
"infoView.dispose.toast.error": "释放实例失败。",
|
||||
} as const
|
||||
|
||||
@@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client"
|
||||
class SDKManager {
|
||||
private clients = new Map<string, OpencodeClient>()
|
||||
|
||||
private key(instanceId: string, worktreeSlug: string): string {
|
||||
return `${instanceId}:${worktreeSlug || "root"}`
|
||||
private key(instanceId: string, proxyPath: string): string {
|
||||
return `${instanceId}:${normalizeProxyPath(proxyPath)}`
|
||||
}
|
||||
|
||||
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
|
||||
const key = this.key(instanceId, worktreeSlug)
|
||||
createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient {
|
||||
const key = this.key(instanceId, proxyPath)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
@@ -23,12 +23,12 @@ class SDKManager {
|
||||
return client
|
||||
}
|
||||
|
||||
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
|
||||
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
|
||||
getClient(instanceId: string, proxyPath: string): OpencodeClient | null {
|
||||
return this.clients.get(this.key(instanceId, proxyPath)) ?? null
|
||||
}
|
||||
|
||||
destroyClient(instanceId: string, worktreeSlug = "root"): void {
|
||||
this.clients.delete(this.key(instanceId, worktreeSlug))
|
||||
destroyClient(instanceId: string, proxyPath: string): void {
|
||||
this.clients.delete(this.key(instanceId, proxyPath))
|
||||
}
|
||||
|
||||
destroyClientsForInstance(instanceId: string): void {
|
||||
@@ -46,7 +46,7 @@ class SDKManager {
|
||||
|
||||
export type { OpencodeClient }
|
||||
|
||||
function buildInstanceBaseUrl(proxyPath: string): string {
|
||||
export function buildInstanceBaseUrl(proxyPath: string): string {
|
||||
const normalized = normalizeProxyPath(proxyPath)
|
||||
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
|
||||
return `${base}${normalized}/`
|
||||
|
||||
@@ -54,6 +54,13 @@ interface BackgroundProcessRemovedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerInstanceDisposedEvent {
|
||||
type: "server.instance.disposed"
|
||||
properties: {
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
type SSEEvent =
|
||||
| MessageUpdateEvent
|
||||
| MessageRemovedEvent
|
||||
@@ -74,6 +81,7 @@ type SSEEvent =
|
||||
| TuiToastEvent
|
||||
| BackgroundProcessUpdatedEvent
|
||||
| BackgroundProcessRemovedEvent
|
||||
| ServerInstanceDisposedEvent
|
||||
| { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
type ConnectionStatus = InstanceStreamStatus
|
||||
@@ -173,6 +181,9 @@ class SSEManager {
|
||||
case "background.process.removed":
|
||||
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
|
||||
break
|
||||
case "server.instance.disposed":
|
||||
this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent)
|
||||
break
|
||||
default:
|
||||
log.warn("Unknown SSE event type", { type: event.type })
|
||||
}
|
||||
@@ -205,6 +216,7 @@ class SSEManager {
|
||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
||||
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
||||
onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void
|
||||
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||
|
||||
getStatus(instanceId: string): ConnectionStatus | null {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permiss
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { getQuestionSessionId } from "../types/question"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
fetchProviders,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import {
|
||||
ensureWorktreesLoaded,
|
||||
ensureWorktreeMapLoaded,
|
||||
getOrCreateWorktreeClient,
|
||||
getWorktreeSlugForSession,
|
||||
reloadWorktreeMap,
|
||||
reloadWorktrees,
|
||||
} from "./worktrees"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
@@ -76,6 +83,9 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000
|
||||
|
||||
const pendingDisposeRequests = new Map<string, Promise<boolean>>()
|
||||
const pendingRehydrations = new Map<string, Promise<void>>()
|
||||
|
||||
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
|
||||
const existing = instances().get(descriptor.id)
|
||||
return {
|
||||
@@ -228,10 +238,15 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
async function hydrateInstanceData(instanceId: string, options?: { force?: boolean }) {
|
||||
try {
|
||||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
if (options?.force) {
|
||||
await reloadWorktrees(instanceId)
|
||||
await reloadWorktreeMap(instanceId)
|
||||
} else {
|
||||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
}
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
@@ -246,6 +261,91 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function postInstanceDispose(instanceId: string): Promise<boolean> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.proxyPath) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const baseUrl = buildInstanceBaseUrl(instance.proxyPath)
|
||||
const url = new URL("instance/dispose", baseUrl)
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Dispose request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = await response.json().catch(() => undefined)
|
||||
if (typeof data === "boolean") return data
|
||||
if (data && typeof data === "object" && "data" in (data as any)) {
|
||||
return Boolean((data as any).data)
|
||||
}
|
||||
return Boolean(data)
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "")
|
||||
if (text.trim() === "true") return true
|
||||
if (text.trim() === "false") return false
|
||||
return Boolean(text)
|
||||
}
|
||||
|
||||
async function rehydrateInstance(instanceId: string, options?: { reason?: string }): Promise<void> {
|
||||
if (pendingRehydrations.has(instanceId)) {
|
||||
return pendingRehydrations.get(instanceId)
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Rehydrating instance", { instanceId, reason: options?.reason })
|
||||
clearCacheForInstance(instanceId)
|
||||
clearCommands(instanceId)
|
||||
clearInstanceMetadata(instanceId)
|
||||
clearInstanceDraftPrompts(instanceId)
|
||||
clearPermissionQueue(instanceId)
|
||||
clearQuestionQueue(instanceId)
|
||||
|
||||
await hydrateInstanceData(instanceId, { force: true })
|
||||
})().finally(() => {
|
||||
pendingRehydrations.delete(instanceId)
|
||||
})
|
||||
|
||||
pendingRehydrations.set(instanceId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function disposeInstance(instanceId: string): Promise<boolean> {
|
||||
if (pendingDisposeRequests.has(instanceId)) {
|
||||
return pendingDisposeRequests.get(instanceId)!
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const ok = await postInstanceDispose(instanceId)
|
||||
if (ok) {
|
||||
await rehydrateInstance(instanceId, { reason: "disposed" })
|
||||
}
|
||||
return ok
|
||||
})().finally(() => {
|
||||
pendingDisposeRequests.delete(instanceId)
|
||||
})
|
||||
|
||||
pendingDisposeRequests.set(instanceId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
@@ -939,6 +1039,30 @@ sseManager.onLspUpdated = async (instanceId) => {
|
||||
}
|
||||
}
|
||||
|
||||
sseManager.onInstanceDisposed = (sourceInstanceId, event) => {
|
||||
const directory = event?.properties?.directory
|
||||
if (!directory) {
|
||||
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
|
||||
return
|
||||
}
|
||||
|
||||
const matchingInstanceIds: string[] = []
|
||||
for (const instance of instances().values()) {
|
||||
if (instance.folder === directory) {
|
||||
matchingInstanceIds.push(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingInstanceIds.length === 0) {
|
||||
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
|
||||
return
|
||||
}
|
||||
|
||||
for (const instanceId of matchingInstanceIds) {
|
||||
void rehydrateInstance(instanceId, { reason: "disposed" })
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
const pending = disconnectedInstance()
|
||||
if (!pending) {
|
||||
@@ -995,4 +1119,5 @@ export {
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
fetchLspStatus,
|
||||
disposeInstance,
|
||||
}
|
||||
|
||||
@@ -63,9 +63,14 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
||||
resolveSelectedModel(instanceProviders, latestProviderId, latestModelId)
|
||||
|
||||
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
let modelInputLimit: number | null = null
|
||||
|
||||
if (selectedModel) {
|
||||
contextWindow = selectedModel.limit?.context ?? 0
|
||||
const inputLimit = selectedModel.limit?.input
|
||||
if (typeof inputLimit === "number" && inputLimit > 0) {
|
||||
modelInputLimit = inputLimit
|
||||
}
|
||||
const outputLimit = selectedModel.limit?.output
|
||||
if (typeof outputLimit === "number" && outputLimit > 0) {
|
||||
modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
@@ -107,7 +112,13 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
||||
|
||||
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
|
||||
if (!contextAvailableFromPrevious) {
|
||||
if (modelInputLimit !== null) {
|
||||
// Prefer explicit input limits when provided by the API.
|
||||
// This is used by the UI "Avail" chip.
|
||||
contextAvailableTokens = modelInputLimit
|
||||
}
|
||||
|
||||
if (!contextAvailableFromPrevious && contextAvailableTokens === null) {
|
||||
if (contextWindow > 0) {
|
||||
if (latestHasContextUsage && actualUsageTokens > 0) {
|
||||
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
|
||||
|
||||
@@ -304,10 +304,10 @@ function setThemePreference(preference: ThemePreference): void {
|
||||
void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error))
|
||||
}
|
||||
|
||||
function setListeningMode(mode: ListeningMode): void {
|
||||
if (serverSettings().listeningMode === mode) return
|
||||
void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error))
|
||||
}
|
||||
async function setListeningMode(mode: ListeningMode): Promise<void> {
|
||||
if (serverSettings().listeningMode === mode) return
|
||||
await patchConfigOwner("server", { listeningMode: mode })
|
||||
}
|
||||
|
||||
function updateEnvironmentVariables(envVars: Record<string, string>): void {
|
||||
void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) =>
|
||||
|
||||
@@ -291,12 +291,13 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||
const initialContextWindow = initialModel?.limit?.context ?? 0
|
||||
const initialInputLimit = initialModel?.limit?.input ?? 0
|
||||
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
||||
const initialOutputLimit =
|
||||
initialModel?.limit?.output && initialModel.limit.output > 0
|
||||
? initialModel.limit.output
|
||||
: DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
|
||||
const initialContextAvailable = initialInputLimit > 0 ? initialInputLimit : initialContextWindow > 0 ? initialContextWindow : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
@@ -398,10 +399,11 @@ async function forkSession(
|
||||
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||
const forkContextWindow = forkModel?.limit?.context ?? 0
|
||||
const forkInputLimit = forkModel?.limit?.input ?? 0
|
||||
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
||||
const forkOutputLimit =
|
||||
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
|
||||
const forkContextAvailable = forkInputLimit > 0 ? forkInputLimit : forkContextWindow > 0 ? forkContextWindow : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
|
||||
@@ -329,12 +329,38 @@ function buildWorktreeProxyPath(instanceId: string, slug: string): string {
|
||||
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
|
||||
}
|
||||
|
||||
function encodeBase64UrlUtf8(input: string): string {
|
||||
const bytes = new TextEncoder().encode(input)
|
||||
// Convert bytes -> base64 (btoa expects a binary string)
|
||||
let binary = ""
|
||||
const chunkSize = 0x8000
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize)
|
||||
binary += String.fromCharCode(...chunk)
|
||||
}
|
||||
const base64 = btoa(binary)
|
||||
// base64 -> base64url (strip padding)
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
|
||||
}
|
||||
|
||||
function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string {
|
||||
const base = buildWorktreeProxyPath(instanceId, slug)
|
||||
const encoded = encodeBase64UrlUtf8(directory)
|
||||
return `${base}/__dir/${encoded}`
|
||||
}
|
||||
|
||||
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
|
||||
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
const proxyPath = buildWorktreeProxyPath(instanceId, normalized)
|
||||
return sdkManager.createClient(instanceId, proxyPath, normalized)
|
||||
}
|
||||
|
||||
function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient {
|
||||
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory)
|
||||
return sdkManager.createClient(instanceId, proxyPath, normalized)
|
||||
}
|
||||
|
||||
function getRootClient(instanceId: string): OpencodeClient {
|
||||
return getOrCreateWorktreeClient(instanceId, "root")
|
||||
}
|
||||
@@ -359,7 +385,9 @@ export {
|
||||
removeParentSessionMapping,
|
||||
getWorktreeSlugForDirectory,
|
||||
buildWorktreeProxyPath,
|
||||
buildWorktreeProxyPathWithDirectoryOverride,
|
||||
getOrCreateWorktreeClient,
|
||||
getOrCreateWorktreeClientWithDirectoryOverride,
|
||||
getRootClient,
|
||||
createWorktree,
|
||||
deleteWorktree,
|
||||
|
||||
@@ -54,3 +54,28 @@ button.button-tertiary:hover:not(:disabled) {
|
||||
button.button-tertiary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.button-danger,
|
||||
button.button-danger {
|
||||
@apply px-6 py-3 text-base rounded-lg;
|
||||
background-color: var(--button-danger-bg);
|
||||
color: var(--button-danger-text);
|
||||
border-color: var(--button-danger-bg);
|
||||
}
|
||||
|
||||
.button-danger:hover:not(:disabled),
|
||||
button.button-danger:hover:not(:disabled) {
|
||||
background-color: var(--button-danger-hover-bg);
|
||||
border-color: var(--button-danger-hover-bg);
|
||||
}
|
||||
|
||||
.button-danger:focus-visible,
|
||||
button.button-danger:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
/* Smaller sizing variant for destructive actions in tight spaces. */
|
||||
.button-danger.button-small,
|
||||
button.button-danger.button-small {
|
||||
@apply px-4 py-2 text-sm;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
line-height: var(--line-height-normal);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-primary);
|
||||
/* Message containers may use `whitespace-pre-wrap` for plain text.
|
||||
Markdown should always match assistant rendering (normal whitespace). */
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.markdown-body p,
|
||||
@@ -28,7 +31,7 @@
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
color: var(--markdown-heading-color, inherit);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.3;
|
||||
margin-top: 0.9em;
|
||||
@@ -71,7 +74,7 @@
|
||||
|
||||
.markdown-body strong {
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--message-assistant-border);
|
||||
color: var(--markdown-accent, var(--message-assistant-border));
|
||||
}
|
||||
|
||||
.markdown-body em {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* Message item base styles */
|
||||
.message-item-base {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
|
||||
/* Markdown rendering uses these to theme emphasis + headings per message role. */
|
||||
--markdown-accent: var(--message-user-border);
|
||||
--markdown-heading-color: var(--message-user-border);
|
||||
}
|
||||
|
||||
.message-item-header {
|
||||
@@ -71,6 +75,9 @@
|
||||
padding: 0.6rem 0.65rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
--markdown-accent: var(--message-assistant-border);
|
||||
--markdown-heading-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-item-base:not(.assistant-message) {
|
||||
|
||||
@@ -295,7 +295,33 @@
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
padding-bottom: 1.5rem;
|
||||
/* Prevent iOS Safari input zoom + keep input compact. */
|
||||
font-size: 16px;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
:root {
|
||||
--prompt-input-compact-height: 104px;
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
min-height: var(--prompt-input-compact-height);
|
||||
}
|
||||
|
||||
.prompt-input-field-container {
|
||||
min-height: var(--prompt-input-compact-height);
|
||||
height: var(--prompt-input-compact-height);
|
||||
}
|
||||
|
||||
.prompt-input-field {
|
||||
height: var(--prompt-input-compact-height);
|
||||
}
|
||||
|
||||
.prompt-input-field-container.is-expanded,
|
||||
.prompt-input-field.is-expanded {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,9 +333,9 @@
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.prompt-input {
|
||||
min-height: 64px;
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-bottom: 2.25rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
}
|
||||
|
||||
.file-viewer-toolbar {
|
||||
@apply ml-auto flex items-center gap-1;
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.file-viewer-toolbar-button {
|
||||
@@ -291,6 +291,22 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.file-viewer-toolbar-icon-button {
|
||||
@apply inline-flex items-center justify-center shrink-0 w-7 h-7 border border-base transition-colors;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.file-viewer-toolbar-icon-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-viewer-toolbar-icon-button.active {
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-primary);
|
||||
}
|
||||
|
||||
.file-viewer-toolbar-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
|
||||
@@ -125,6 +125,18 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-fullscreen-exit-wrapper {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top, 0px) + 12px);
|
||||
right: calc(env(safe-area-inset-right, 0px) + 12px);
|
||||
z-index: 1250;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-fullscreen-exit-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-danger,
|
||||
button.button-danger,
|
||||
.button-tertiary,
|
||||
button.button-tertiary) {
|
||||
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
|
||||
@@ -74,6 +76,8 @@
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-danger,
|
||||
button.button-danger,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):focus-visible {
|
||||
outline: none;
|
||||
@@ -84,6 +88,8 @@
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-danger,
|
||||
button.button-danger,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface Model {
|
||||
variantKeys?: string[]
|
||||
limit?: {
|
||||
context?: number
|
||||
input?: number
|
||||
output?: number
|
||||
}
|
||||
cost?: {
|
||||
|
||||
BIN
temp/opencode-ai-sdk-1.2.6.tgz
Normal file
BIN
temp/opencode-ai-sdk-1.2.6.tgz
Normal file
Binary file not shown.
51
temp/package/package.json
Normal file
51
temp/package/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "./script/build.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/client.js",
|
||||
"types": "./dist/client.d.ts"
|
||||
},
|
||||
"./server": {
|
||||
"import": "./dist/server.js",
|
||||
"types": "./dist/server.d.ts"
|
||||
},
|
||||
"./v2": {
|
||||
"import": "./dist/v2/index.js",
|
||||
"types": "./dist/v2/index.d.ts"
|
||||
},
|
||||
"./v2/client": {
|
||||
"import": "./dist/v2/client.js",
|
||||
"types": "./dist/v2/client.d.ts"
|
||||
},
|
||||
"./v2/server": {
|
||||
"import": "./dist/v2/server.js",
|
||||
"types": "./dist/v2/server.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/node": "22.13.9",
|
||||
"typescript": "5.8.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1"
|
||||
},
|
||||
"dependencies": {},
|
||||
"publishConfig": {
|
||||
"directory": "dist"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user