Compare commits
21 Commits
v0.10.3-de
...
v0.11.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dee154490 | ||
|
|
ef388adc4f | ||
|
|
e8cfad1266 | ||
|
|
3f82dd21fe | ||
|
|
dc13d9a7d0 | ||
|
|
29557fba6d | ||
|
|
dea5079713 | ||
|
|
ddc58a2c3c | ||
|
|
eafd4d83af | ||
|
|
1a0734c6b1 | ||
|
|
e16c5752ed | ||
|
|
375f92410e | ||
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
eb6701185b | ||
|
|
d948ad8e35 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
1ef01da019 |
@@ -123,3 +123,6 @@ To build the Desktop App from source:
|
|||||||
1. Clone the repo.
|
1. Clone the repo.
|
||||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
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",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -2809,9 +2809,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.1.11",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
||||||
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -11985,7 +11985,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12021,7 +12021,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12062,7 +12062,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12070,12 +12070,12 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.11",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.10.3",
|
"minServerVersion": "0.11.1",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -119,7 +119,8 @@
|
|||||||
showError(message || `Login failed (${res.status})`)
|
showError(message || `Login failed (${res.status})`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.location.href = "/"
|
// Replace history entry so Back doesn't return to /login.
|
||||||
|
window.location.replace("/")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(e && e.message ? e.message : String(e))
|
showError(e && e.message ? e.message : String(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,19 @@ function getTokenHtml(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
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()
|
const status = deps.authManager.getStatus()
|
||||||
reply.type("text/html").send(getLoginHtml(status.username))
|
reply.type("text/html").send(getLoginHtml(status.username))
|
||||||
})
|
})
|
||||||
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return
|
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())
|
reply.type("text/html").send(getTokenHtml())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.3",
|
"version": "0.11.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.11",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Toaster } from "solid-toast"
|
import { Toaster } from "solid-toast"
|
||||||
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
|
import { Minimize2 } from "lucide-solid"
|
||||||
import AlertDialog from "./components/alert-dialog"
|
import AlertDialog from "./components/alert-dialog"
|
||||||
import FolderSelectionView from "./components/folder-selection-view"
|
import FolderSelectionView from "./components/folder-selection-view"
|
||||||
import { showConfirmDialog } from "./stores/alerts"
|
import { showConfirmDialog } from "./stores/alerts"
|
||||||
@@ -82,6 +84,46 @@ const App: Component = () => {
|
|||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
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(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
@@ -95,6 +137,56 @@ const App: Component = () => {
|
|||||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
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(() => {
|
createEffect(() => {
|
||||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||||
})
|
})
|
||||||
@@ -405,19 +497,34 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</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
|
<Show
|
||||||
when={!hasInstances()}
|
when={!hasInstances()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<InstanceTabs
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
instances={instances()}
|
<InstanceTabs
|
||||||
activeInstanceId={activeInstanceId()}
|
instances={instances()}
|
||||||
onSelect={setActiveInstanceId}
|
activeInstanceId={activeInstanceId()}
|
||||||
onClose={handleCloseInstance}
|
onSelect={setActiveInstanceId}
|
||||||
onNew={handleNewInstanceRequest}
|
onClose={handleCloseInstance}
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
onNew={handleNewInstanceRequest}
|
||||||
/>
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<For each={Array.from(instances().values())}>
|
<For each={Array.from(instances().values())}>
|
||||||
{(instance) => {
|
{(instance) => {
|
||||||
@@ -435,7 +542,10 @@ const App: Component = () => {
|
|||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
onExecuteCommand={executeCommand}
|
onExecuteCommand={executeCommand}
|
||||||
tabBarOffset={instanceTabBarHeight()}
|
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||||
|
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||||
|
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||||
|
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||||
/>
|
/>
|
||||||
</InstanceMetadataProvider>
|
</InstanceMetadataProvider>
|
||||||
|
|
||||||
|
|||||||
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
|
after: string
|
||||||
viewMode?: "split" | "unified"
|
viewMode?: "split" | "unified"
|
||||||
contextMode?: "expanded" | "collapsed"
|
contextMode?: "expanded" | "collapsed"
|
||||||
|
wordWrap?: "on" | "off"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
@@ -54,7 +55,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
renderWhitespace: "selection",
|
renderWhitespace: "selection",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
wordWrap: "off",
|
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||||
glyphMargin: false,
|
glyphMargin: false,
|
||||||
folding: false,
|
folding: false,
|
||||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
// 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
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
|
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||||
|
|
||||||
diffEditor.updateOptions({
|
diffEditor.updateOptions({
|
||||||
renderSideBySide: viewMode === "split",
|
renderSideBySide: viewMode === "split",
|
||||||
@@ -89,7 +91,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
contextMode === "collapsed"
|
contextMode === "collapsed"
|
||||||
? { enabled: true }
|
? { enabled: true }
|
||||||
: { enabled: false },
|
: { enabled: false },
|
||||||
|
wordWrap,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner"
|
|||||||
import PermissionApprovalModal from "../permission-approval-modal"
|
import PermissionApprovalModal from "../permission-approval-modal"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
import { formatTokenTotal } from "../../lib/formatters"
|
import { formatTokenTotal } from "../../lib/formatters"
|
||||||
|
import ContextMeter from "../context-meter"
|
||||||
import { sseManager } from "../../lib/sse-manager"
|
import { sseManager } from "../../lib/sse-manager"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
@@ -41,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
|||||||
import RightPanel from "./shell/right-panel/RightPanel"
|
import RightPanel from "./shell/right-panel/RightPanel"
|
||||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||||
import { getSessionStatus } from "../../stores/session-status"
|
import { getSessionStatus } from "../../stores/session-status"
|
||||||
import { ShieldAlert } from "lucide-solid"
|
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||||
|
|
||||||
import type { LayoutMode } from "./shell/types"
|
import type { LayoutMode } from "./shell/types"
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +70,11 @@ interface InstanceShellProps {
|
|||||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||||
onExecuteCommand: (command: Command) => void
|
onExecuteCommand: (command: Command) => void
|
||||||
tabBarOffset: number
|
tabBarOffset: number
|
||||||
|
|
||||||
|
// In-memory only: mobile immersive/fullscreen mode.
|
||||||
|
mobileFullscreenMode: boolean
|
||||||
|
onEnterMobileFullscreen: () => void
|
||||||
|
onExitMobileFullscreen: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||||
@@ -117,6 +123,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
||||||
|
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
||||||
|
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
||||||
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||||
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||||
|
|
||||||
@@ -349,16 +357,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
measureDrawerHost,
|
measureDrawerHost,
|
||||||
})
|
})
|
||||||
|
|
||||||
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
|
|
||||||
|
|
||||||
|
|
||||||
const formattedAvailableTokens = () => {
|
|
||||||
const avail = tokenStats().avail
|
|
||||||
if (typeof avail === "number") {
|
|
||||||
return formatTokenTotal(avail)
|
|
||||||
}
|
|
||||||
return "--"
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderLeftPanel = () => {
|
const renderLeftPanel = () => {
|
||||||
if (leftPinned()) {
|
if (leftPinned()) {
|
||||||
@@ -594,13 +592,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
{renderLeftPanel()}
|
{renderLeftPanel()}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
<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">
|
<Show when={!mobileFullscreen()}>
|
||||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||||
<Show
|
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||||
when={!isPhoneLayout()}
|
<Show
|
||||||
fallback={
|
when={!isPhoneLayout()}
|
||||||
<div class="flex flex-col w-full gap-1.5">
|
fallback={
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
<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"}>
|
<Show when={leftDrawerState() === "floating-closed"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setLeftToggleButtonEl}
|
ref={setLeftToggleButtonEl}
|
||||||
@@ -647,6 +646,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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"}>
|
<Show when={rightDrawerState() === "floating-closed"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setRightToggleButtonEl}
|
ref={setRightToggleButtonEl}
|
||||||
@@ -661,20 +672,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
<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">
|
<ContextMeter
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
usedTokens={tokenStats().used}
|
||||||
{t("instanceShell.metrics.usedLabel")}
|
availableTokens={tokenStats().avail}
|
||||||
</span>
|
formatTokens={formatTokenTotal}
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||||
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -693,18 +699,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!showingInfoView()}>
|
<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">
|
<ContextMeter
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
usedTokens={tokenStats().used}
|
||||||
{t("instanceShell.metrics.usedLabel")}
|
availableTokens={tokenStats().avail}
|
||||||
</span>
|
formatTokens={formatTokenTotal}
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||||
</div>
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||||
<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>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center session-header-hints">
|
<div class="ml-auto flex items-center session-header-hints">
|
||||||
@@ -769,9 +770,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
@@ -808,6 +810,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
instanceFolder={props.instance.folder}
|
instanceFolder={props.instance.folder}
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
|
isPhoneLayout={isPhoneLayout()}
|
||||||
|
compactPromptLayout={compactPromptLayout()}
|
||||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||||
onSidebarToggle={() => setLeftOpen(true)}
|
onSidebarToggle={() => setLeftOpen(true)}
|
||||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance"
|
|||||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
import type { Session } from "../../../../types/session"
|
import type { Session } from "../../../../types/session"
|
||||||
import type { DrawerViewState } from "../types"
|
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 ChangesTab from "./tabs/ChangesTab"
|
||||||
import FilesTab from "./tabs/FilesTab"
|
import FilesTab from "./tabs/FilesTab"
|
||||||
@@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
|||||||
import {
|
import {
|
||||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_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_NONPHONE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
@@ -102,6 +103,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
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 [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||||
const [filesSplitWidth, setFilesSplitWidth] = 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())
|
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 clampSplitWidth = (value: number) => {
|
||||||
const min = 200
|
const min = 200
|
||||||
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||||
@@ -738,8 +747,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
onSelectFile={handleSelectChangesFile}
|
onSelectFile={handleSelectChangesFile}
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
diffContextMode={diffContextMode}
|
diffContextMode={diffContextMode}
|
||||||
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
listOpen={changesListOpen}
|
listOpen={changesListOpen}
|
||||||
onToggleList={toggleChangesList}
|
onToggleList={toggleChangesList}
|
||||||
splitWidth={changesSplitWidth}
|
splitWidth={changesSplitWidth}
|
||||||
@@ -765,8 +776,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
scopeKey={gitScopeKey}
|
scopeKey={gitScopeKey}
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
diffContextMode={diffContextMode}
|
diffContextMode={diffContextMode}
|
||||||
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onOpenFile={(path) => void openGitFile(path)}
|
onOpenFile={(path) => void openGitFile(path)}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
onRefresh={() => void refreshGitStatus()}
|
||||||
listOpen={gitChangesListOpen}
|
listOpen={gitChangesListOpen}
|
||||||
|
|||||||
@@ -1,50 +1,61 @@
|
|||||||
import type { Component } from "solid-js"
|
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 {
|
interface DiffToolbarProps {
|
||||||
viewMode: DiffViewMode
|
viewMode: DiffViewMode
|
||||||
contextMode: DiffContextMode
|
contextMode: DiffContextMode
|
||||||
|
wordWrapMode: DiffWordWrapMode
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
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 (
|
return (
|
||||||
<div class="file-viewer-toolbar">
|
<div class="file-viewer-toolbar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={props.viewMode === "split"}
|
onClick={() => props.onViewModeChange(nextViewMode())}
|
||||||
onClick={() => props.onViewModeChange("split")}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={props.viewMode === "unified"}
|
onClick={() => props.onContextModeChange(nextContextMode())}
|
||||||
onClick={() => props.onViewModeChange("unified")}
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
|
||||||
aria-pressed={props.contextMode === "collapsed"}
|
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
|
||||||
onClick={() => props.onContextModeChange("collapsed")}
|
aria-label={wordWrapTitle()}
|
||||||
title="Hide unchanged regions"
|
title={wordWrapTitle()}
|
||||||
>
|
>
|
||||||
Collapsed
|
<WrapText class="h-4 w-4" aria-hidden="true" />
|
||||||
</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
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
|||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
interface ChangesTabProps {
|
interface ChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -18,8 +18,10 @@ interface ChangesTabProps {
|
|||||||
|
|
||||||
diffViewMode: Accessor<DiffViewMode>
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
diffContextMode: Accessor<DiffContextMode>
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
listOpen: Accessor<boolean>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -77,14 +79,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<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">
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
<Show
|
<Show
|
||||||
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
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 || "")}
|
after={String((file() as any).after || "")}
|
||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -182,6 +177,17 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 }}
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
|||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
interface GitChangesTabProps {
|
interface GitChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -29,8 +29,10 @@ interface GitChangesTabProps {
|
|||||||
|
|
||||||
diffViewMode: Accessor<DiffViewMode>
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
diffContextMode: Accessor<DiffContextMode>
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
@@ -80,14 +82,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<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">
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
<Show
|
<Show
|
||||||
when={props.selectedLoading()}
|
when={props.selectedLoading()}
|
||||||
@@ -122,6 +116,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
after={String((file() as any).after || "")}
|
after={String((file() as any).after || "")}
|
||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -237,6 +232,15 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||||
</button>
|
</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 }}
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
|||||||
export type DiffViewMode = "split" | "unified"
|
export type DiffViewMode = "split" | "unified"
|
||||||
|
|
||||||
export type DiffContextMode = "expanded" | "collapsed"
|
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_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_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_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) =>
|
export const clampWidth = (value: number) =>
|
||||||
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Show, createSignal } from "solid-js"
|
import { For, Show, createSignal } from "solid-js"
|
||||||
import { Copy, Split, Trash2, Undo } from "lucide-solid"
|
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
|
||||||
import type { MessageInfo, ClientPart } from "../types/message"
|
import type { MessageInfo, ClientPart } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
|
|||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessagePart } from "../stores/session-actions"
|
||||||
|
import { isTauriHost } from "../lib/runtime-env"
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
record: MessageRecord
|
record: MessageRecord
|
||||||
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const messageParts = () => props.parts
|
const messageParts = () => props.parts
|
||||||
|
|
||||||
|
// User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads).
|
||||||
|
// We only want to display the primary prompt text for the user message; other synthetic text
|
||||||
|
// parts should be hidden.
|
||||||
|
const primaryUserTextPartId = () => {
|
||||||
|
if (!isUser()) return null
|
||||||
|
const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined
|
||||||
|
return typeof firstText?.id === "string" ? firstText.id : null
|
||||||
|
}
|
||||||
|
|
||||||
const fileAttachments = () =>
|
const fileAttachments = () =>
|
||||||
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||||
|
|
||||||
@@ -96,7 +106,8 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith("file://")) {
|
if (url.startsWith("file://")) {
|
||||||
window.open(url, "_blank", "noopener")
|
// Local filesystem URLs are not reliably downloadable from the message stream.
|
||||||
|
// We hide the download action for these chips.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +384,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
messageType={props.record.role}
|
messageType={props.record.role}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
|
primaryUserTextPartId={primaryUserTextPartId()}
|
||||||
onRendered={props.onContentRendered}
|
onRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -399,17 +411,20 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate max-w-[180px]">{name}</span>
|
<span class="truncate max-w-[180px]">{name}</span>
|
||||||
<button
|
<Show when={!attachment.url?.startsWith("file://")}>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void handleAttachmentDownload(attachment)}
|
type="button"
|
||||||
class="attachment-download"
|
onClick={() => void handleAttachmentDownload(attachment)}
|
||||||
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
class="attachment-download"
|
||||||
>
|
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
title={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||||
</button>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import ContextMeter from "./context-meter"
|
||||||
import { useI18n } from "../lib/i18n"
|
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 {
|
interface MessageListHeaderProps {
|
||||||
usedTokens: number
|
usedTokens: number
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
<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-text connection-status-info">
|
||||||
<div class="connection-status-usage">
|
<div class="connection-status-usage">
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
<ContextMeter
|
||||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
usedTokens={props.usedTokens}
|
||||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
|
||||||
</div>
|
formatTokens={props.formatTokens}
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
usedLabel={t("messageListHeader.metrics.usedLabel")}
|
||||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
availableLabel={t("messageListHeader.metrics.availableLabel")}
|
||||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ interface MessagePartProps {
|
|||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
|
||||||
|
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
||||||
|
primaryUserTextPartId?: string | null
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
}
|
}
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
@@ -28,8 +31,19 @@ interface MessagePartProps {
|
|||||||
const shouldHideTextPart = () => {
|
const shouldHideTextPart = () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if (!part || part.type !== "text") return false
|
if (!part || part.type !== "text") return false
|
||||||
// Keep optimistic user prompts visible; hide synthetic assistant text.
|
|
||||||
return Boolean((part as any).synthetic) && props.messageType !== "user"
|
const isSynthetic = Boolean((part as any).synthetic)
|
||||||
|
if (!isSynthetic) return false
|
||||||
|
|
||||||
|
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
|
||||||
|
if (props.messageType === "user") {
|
||||||
|
const primaryId = props.primaryUserTextPartId
|
||||||
|
if (!primaryId) return false
|
||||||
|
return part.id !== primaryId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide synthetic assistant text.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
const activeElement = document.activeElement as HTMLElement
|
const activeElement = document.activeElement as HTMLElement | null
|
||||||
|
|
||||||
const isInputElement =
|
const isInputElement =
|
||||||
activeElement?.tagName === "INPUT" ||
|
activeElement?.tagName === "INPUT" ||
|
||||||
activeElement?.tagName === "TEXTAREA" ||
|
activeElement?.tagName === "TEXTAREA" ||
|
||||||
activeElement?.tagName === "SELECT" ||
|
activeElement?.tagName === "SELECT" ||
|
||||||
activeElement?.isContentEditable
|
Boolean(activeElement?.isContentEditable)
|
||||||
|
|
||||||
if (isInputElement) return
|
if (isInputElement) return
|
||||||
|
|
||||||
@@ -192,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (isModifierKey) return
|
if (isModifierKey) return
|
||||||
|
|
||||||
const isSpecialKey =
|
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 (isSpecialKey) return
|
||||||
|
|
||||||
if (e.key.length === 1 && textareaRef && !props.disabled) {
|
const textarea = textareaRef
|
||||||
textareaRef.focus()
|
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)
|
document.addEventListener("keydown", handleGlobalKeyDown)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.removeEventListener("keydown", handleGlobalKeyDown)
|
document.removeEventListener("keydown", handleGlobalKeyDown)
|
||||||
})
|
})
|
||||||
@@ -435,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
rows={expandState() === "expanded" ? 15 : 4}
|
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export interface PromptInputProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
instanceFolder: string
|
instanceFolder: string
|
||||||
sessionId: 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>
|
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||||
onRunShell?: (command: string) => Promise<void>
|
onRunShell?: (command: string) => Promise<void>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|||||||
@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||||||
|
|
||||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||||
const currentAttachments = options.getAttachments()
|
const currentAttachments = options.getAttachments()
|
||||||
const attachment = currentAttachments.find(
|
const attachment = currentAttachments.find((a) => {
|
||||||
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
if (a.source.type === "agent") {
|
||||||
)
|
return a.filename === name
|
||||||
|
}
|
||||||
|
if (a.source.type === "file") {
|
||||||
|
// Match either by filename (basename) or by path (for full paths like @docs/file.txt)
|
||||||
|
return (
|
||||||
|
a.filename === name ||
|
||||||
|
a.source.path === name ||
|
||||||
|
a.source.path.endsWith("/" + name) ||
|
||||||
|
a.source.path === name.replace(/\/$/, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (a.source.type === "text") {
|
||||||
|
// For text attachments (path-only mentions), match by value
|
||||||
|
return a.source.value === name || a.source.value.endsWith("/" + name)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||||||
textarea.setSelectionRange(mentionStart, mentionStart)
|
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
|
// Check if there are any @ remaining in the text - if not, close the picker
|
||||||
|
if (!newText.includes("@") && options.isPickerOpen()) {
|
||||||
|
options.closePicker()
|
||||||
|
// Clear ignoredAtPositions since we deleted the entire @mention
|
||||||
|
// This ensures typing @ again will open the picker
|
||||||
|
options.setIgnoredAtPositions(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import type { Agent } from "../../types/session"
|
import type { Agent } from "../../types/session"
|
||||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||||
import type { PickerMode } from "./types"
|
import type { PickerMode } from "./types"
|
||||||
|
import type { PickerSelectAction } from "../unified-picker"
|
||||||
|
|
||||||
type PickerItem =
|
type PickerItem =
|
||||||
| { type: "agent"; agent: Agent }
|
| { type: "agent"; agent: Agent }
|
||||||
@@ -37,7 +38,7 @@ type PromptPickerController = {
|
|||||||
setIgnoredAtPositions: Setter<Set<number>>
|
setIgnoredAtPositions: Setter<Set<number>>
|
||||||
|
|
||||||
handleInput: (e: Event) => void
|
handleInput: (e: Event) => void
|
||||||
handlePickerSelect: (item: PickerItem) => void
|
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
handlePickerClose: () => void
|
handlePickerClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePickerSelect(item: PickerItem) {
|
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
|
||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
|
|
||||||
if (item.type === "command") {
|
if (item.type === "command") {
|
||||||
|
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const name = item.command.name
|
const name = item.command.name
|
||||||
const currentPrompt = options.prompt()
|
const currentPrompt = options.prompt()
|
||||||
|
|
||||||
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
} else if (item.type === "agent") {
|
} else if (item.type === "agent") {
|
||||||
|
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const agentName = item.agent.name
|
const agentName = item.agent.name
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
@@ -163,76 +166,152 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const relativePath = item.file.relativePath ?? displayPath
|
const relativePath = item.file.relativePath ?? displayPath
|
||||||
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||||
|
|
||||||
if (isFolder) {
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const pos = atPosition()
|
|
||||||
const cursorPos = textarea?.selectionStart || 0
|
|
||||||
const folderMention =
|
|
||||||
relativePath === "." || relativePath === ""
|
|
||||||
? "/"
|
|
||||||
: relativePath.replace(/\/+$/, "") + "/"
|
|
||||||
|
|
||||||
if (pos !== null) {
|
|
||||||
const before = currentPrompt.substring(0, pos + 1)
|
|
||||||
const after = currentPrompt.substring(cursorPos)
|
|
||||||
const newPrompt = before + folderMention + after
|
|
||||||
options.setPrompt(newPrompt)
|
|
||||||
setSearchQuery(folderMention)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const nextTextarea = options.getTextarea()
|
|
||||||
if (nextTextarea) {
|
|
||||||
const newCursorPos = pos + 1 + folderMention.length
|
|
||||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
|
||||||
const pathSegments = normalizedPath.split("/")
|
|
||||||
const filename = (() => {
|
|
||||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
|
||||||
return candidate === "." ? "/" : candidate
|
|
||||||
})()
|
|
||||||
|
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
|
||||||
const alreadyAttached = existingAttachments.some(
|
|
||||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!alreadyAttached) {
|
|
||||||
const attachment = createFileAttachment(
|
|
||||||
normalizedPath,
|
|
||||||
filename,
|
|
||||||
"text/plain",
|
|
||||||
undefined,
|
|
||||||
options.instanceFolder(),
|
|
||||||
)
|
|
||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
const cursorPos = textarea?.selectionStart || 0
|
const cursorPos = textarea?.selectionStart || 0
|
||||||
|
|
||||||
if (pos !== null) {
|
const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => {
|
||||||
|
if (pos === null) return
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
const before = currentPrompt.substring(0, pos)
|
const before = currentPrompt.substring(0, pos)
|
||||||
const after = currentPrompt.substring(cursorPos)
|
const after = currentPrompt.substring(cursorPos)
|
||||||
const attachmentText = `@${normalizedPath}`
|
const suffix = opts?.trailingSpace ? " " : ""
|
||||||
const newPrompt = before + attachmentText + " " + after
|
const nextPrompt = before + mentionText + suffix + after
|
||||||
options.setPrompt(newPrompt)
|
options.setPrompt(nextPrompt)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const nextTextarea = options.getTextarea()
|
const nextTextarea = options.getTextarea()
|
||||||
if (nextTextarea) {
|
if (!nextTextarea) return
|
||||||
const newCursorPos = pos + attachmentText.length + 1
|
const nextCursorPos = pos + mentionText.length + suffix.length
|
||||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
|
||||||
}
|
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replaceMentionQueryAfterAt = (value: string) => {
|
||||||
|
// Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation.
|
||||||
|
if (pos === null) return
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const before = currentPrompt.substring(0, pos + 1)
|
||||||
|
const after = currentPrompt.substring(cursorPos)
|
||||||
|
const nextPrompt = before + value + after
|
||||||
|
options.setPrompt(nextPrompt)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (!nextTextarea) return
|
||||||
|
const nextCursorPos = pos + 1 + value.length
|
||||||
|
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderMention =
|
||||||
|
relativePath === "." || relativePath === "" || relativePath === "./"
|
||||||
|
? "./"
|
||||||
|
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
|
||||||
|
|
||||||
|
const normalizedFolderPath = (() => {
|
||||||
|
const trimmed = relativePath.replace(/\/+$/, "")
|
||||||
|
// If it's root "./", just return "./"
|
||||||
|
if (trimmed === "" || trimmed === ".") return "./"
|
||||||
|
// Otherwise remove any leading ./ and add ./ prefix
|
||||||
|
return "./" + trimmed.replace(/^\.\//, "")
|
||||||
|
})()
|
||||||
|
|
||||||
|
const addPathOnlyAttachment = (value: string) => {
|
||||||
|
const display = `path: ${value}`
|
||||||
|
const filename = value
|
||||||
|
const existing = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existing.some(
|
||||||
|
(att) => att.source.type === "text" && att.source.value === value && att.display === display,
|
||||||
|
)
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
if (action === "tab") {
|
||||||
|
// TAB on directory: autocomplete directory name and show its contents.
|
||||||
|
replaceMentionQueryAfterAt(folderMention)
|
||||||
|
setSearchQuery(folderMention)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionText = `@${folderMention}`
|
||||||
|
|
||||||
|
if (action === "shiftEnter") {
|
||||||
|
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
|
||||||
|
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
|
||||||
|
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||||
|
} else {
|
||||||
|
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
|
||||||
|
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
||||||
|
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
||||||
|
|
||||||
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existingAttachments.some(
|
||||||
|
(att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
const attachment = createFileAttachment(
|
||||||
|
normalizedFolderPath,
|
||||||
|
dirFilename,
|
||||||
|
"inode/directory",
|
||||||
|
undefined,
|
||||||
|
options.instanceFolder(),
|
||||||
|
)
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
||||||
|
|
||||||
|
if (action === "tab") {
|
||||||
|
// TAB on file: autocomplete the file path but do not attach.
|
||||||
|
replaceMentionToken(`@${normalizedPath}`)
|
||||||
|
setSearchQuery(normalizedPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "shiftEnter") {
|
||||||
|
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||||
|
addPathOnlyAttachment(normalizedPathWithPrefix)
|
||||||
|
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||||
|
} else {
|
||||||
|
// ENTER/click on file: attach file (existing behavior).
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||||
|
const pathSegments = normalizedPath.split("/")
|
||||||
|
const filename = (() => {
|
||||||
|
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||||
|
return candidate === "." ? "/" : candidate
|
||||||
|
})()
|
||||||
|
|
||||||
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existingAttachments.some(
|
||||||
|
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
const attachment = createFileAttachment(
|
||||||
|
normalizedPathWithPrefix,
|
||||||
|
filename,
|
||||||
|
"text/plain",
|
||||||
|
undefined,
|
||||||
|
options.instanceFolder(),
|
||||||
|
)
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
@@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pickerMode() === "mention" && pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
|
|
||||||
|
// Remove the partial @mention text from the textarea when ESC is pressed
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
if (textarea) {
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const cursorPos = textarea.selectionStart
|
||||||
|
// Remove text from @ position to cursor position
|
||||||
|
const before = currentPrompt.substring(0, pos)
|
||||||
|
const after = currentPrompt.substring(cursorPos)
|
||||||
|
options.setPrompt(before + after)
|
||||||
|
|
||||||
|
// Restore cursor position to where @ was
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (nextTextarea) {
|
||||||
|
nextTextarea.setSelectionRange(pos, pos)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
// Clear ignoredAtPositions so typing @ again will work
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
|
||||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
@@ -88,6 +89,10 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (applyingListeningMode()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||||
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
@@ -100,12 +105,21 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setListeningMode(targetMode)
|
setApplyingListeningMode(true)
|
||||||
const restarted = await restartCli()
|
setError(null)
|
||||||
if (!restarted) {
|
try {
|
||||||
setError(t("remoteAccess.restart.errorManual"))
|
// Important: await the config patch before restart so Electron reads the updated mode from disk.
|
||||||
} else {
|
await setListeningMode(targetMode)
|
||||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
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()
|
void refreshMeta()
|
||||||
@@ -196,6 +210,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
onChange={(nextChecked) => {
|
onChange={(nextChecked) => {
|
||||||
void handleAllowConnectionsChange(nextChecked)
|
void handleAllowConnectionsChange(nextChecked)
|
||||||
}}
|
}}
|
||||||
|
disabled={loading() || applyingListeningMode()}
|
||||||
>
|
>
|
||||||
<Switch.Input />
|
<Switch.Input />
|
||||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface SessionViewProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
instanceFolder: string
|
instanceFolder: string
|
||||||
escapeInDebounce: boolean
|
escapeInDebounce: boolean
|
||||||
|
isPhoneLayout?: boolean
|
||||||
|
compactPromptLayout?: boolean
|
||||||
showSidebarToggle?: boolean
|
showSidebarToggle?: boolean
|
||||||
onSidebarToggle?: () => void
|
onSidebarToggle?: () => void
|
||||||
forceCompactStatusLayout?: boolean
|
forceCompactStatusLayout?: boolean
|
||||||
@@ -76,6 +78,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
(isActive) => {
|
(isActive) => {
|
||||||
if (!isActive) return
|
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.)
|
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const activeEl = document.activeElement as HTMLElement | null
|
const activeEl = document.activeElement as HTMLElement | null
|
||||||
@@ -314,17 +319,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
instanceFolder={props.instanceFolder}
|
instanceFolder={props.instanceFolder}
|
||||||
sessionId={activeSession.id}
|
sessionId={activeSession.id}
|
||||||
onSend={handleSendMessage}
|
isActive={props.isActive}
|
||||||
onRunShell={handleRunShell}
|
compactLayout={props.compactPromptLayout}
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
onSend={handleSendMessage}
|
||||||
isSessionBusy={sessionBusy()}
|
onRunShell={handleRunShell}
|
||||||
disabled={sessionNeedsInput()}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
onAbortSession={handleAbortSession}
|
isSessionBusy={sessionBusy()}
|
||||||
registerPromptInputApi={registerPromptInputApi}
|
disabled={sessionNeedsInput()}
|
||||||
/>
|
onAbortSession={handleAbortSession}
|
||||||
|
registerPromptInputApi={registerPromptInputApi}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ function normalizeQuery(rawQuery: string) {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if (trimmed === "." || trimmed === "./") {
|
// Don't normalize "." - it's used for workspace root
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +72,12 @@ type PickerItem =
|
|||||||
| { type: "file"; file: FileItem }
|
| { type: "file"; file: FileItem }
|
||||||
| { type: "command"; command: SDKCommand }
|
| { type: "command"; command: SDKCommand }
|
||||||
|
|
||||||
|
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
|
||||||
|
|
||||||
interface UnifiedPickerProps {
|
interface UnifiedPickerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
mode?: "mention" | "command"
|
mode?: "mention" | "command"
|
||||||
onSelect: (item: PickerItem) => void
|
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
commands?: SDKCommand[]
|
commands?: SDKCommand[]
|
||||||
@@ -266,6 +266,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
||||||
const queryChanged = lastQuery !== props.searchQuery
|
const queryChanged = lastQuery !== props.searchQuery
|
||||||
|
|
||||||
|
if (queryChanged) {
|
||||||
|
// Reset selectedIndex to 0 when query changes to avoid ghost state
|
||||||
|
// This ensures proper highlighting when navigating back to root or changing queries
|
||||||
|
setSelectedIndex(0)
|
||||||
|
resetScrollPosition()
|
||||||
|
}
|
||||||
|
|
||||||
if (!isInitialized() || workspaceChanged || queryChanged) {
|
if (!isInitialized() || workspaceChanged || queryChanged) {
|
||||||
setIsInitialized(true)
|
setIsInitialized(true)
|
||||||
lastWorkspaceId = props.workspaceId
|
lastWorkspaceId = props.workspaceId
|
||||||
@@ -341,7 +348,22 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/")
|
||||||
|
const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./"
|
||||||
|
if (mode() === "mention" && isExactRootQuery) {
|
||||||
|
const rootFile: FileItem = {
|
||||||
|
path: ".",
|
||||||
|
relativePath: ".",
|
||||||
|
isDirectory: true,
|
||||||
|
isGitFile: false,
|
||||||
|
}
|
||||||
|
items.push({ type: "file", file: rootFile })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show agents for exact root path queries
|
||||||
|
if (!isExactRootQuery) {
|
||||||
|
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||||
|
}
|
||||||
files().forEach((file) => items.push({ type: "file", file }))
|
files().forEach((file) => items.push({ type: "file", file }))
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
@@ -356,7 +378,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(item: PickerItem) {
|
function handleSelect(item: PickerItem) {
|
||||||
props.onSelect(item)
|
props.onSelect(item, "click")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
@@ -379,7 +401,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const selected = items[selectedIndex()]
|
const selected = items[selectedIndex()]
|
||||||
if (selected) {
|
if (selected) {
|
||||||
handleSelect(selected)
|
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||||
|
props.onSelect(selected, action)
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -443,7 +466,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
||||||
data-picker-selected={isSelected()}
|
data-picker-selected={isSelected()}
|
||||||
onClick={() => handleSelect({ type: "command", command })}
|
onClick={() => props.onSelect({ type: "command", command }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -464,7 +487,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={mode() === "mention" && agentCount() > 0}>
|
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
{t("unifiedPicker.sections.agents")}
|
{t("unifiedPicker.sections.agents")}
|
||||||
</div>
|
</div>
|
||||||
@@ -479,7 +502,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||||
}`}
|
}`}
|
||||||
data-picker-selected={itemIndex === selectedIndex()}
|
data-picker-selected={itemIndex === selectedIndex()}
|
||||||
onClick={() => handleSelect({ type: "agent", agent })}
|
onClick={() => props.onSelect({ type: "agent", agent }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<svg
|
<svg
|
||||||
@@ -519,10 +542,39 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={mode() === "mention" && fileCount() > 0}>
|
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
{t("unifiedPicker.sections.files")}
|
{t("unifiedPicker.sections.files")}
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={props.searchQuery === "." || props.searchQuery === "./"}>
|
||||||
|
<div
|
||||||
|
class={`dropdown-item py-1.5 ${
|
||||||
|
selectedIndex() === 0 ? "dropdown-item-highlight" : ""
|
||||||
|
}`}
|
||||||
|
data-picker-selected={selectedIndex() === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const rootFile: FileItem = {
|
||||||
|
path: ".",
|
||||||
|
relativePath: ".",
|
||||||
|
isDirectory: true,
|
||||||
|
isGitFile: false,
|
||||||
|
}
|
||||||
|
props.onSelect({ type: "file", file: rootFile }, "click")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<svg class="dropdown-icon h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">. {t("unifiedPicker.sections.workspaceRoot")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
const itemIndex = allItems().findIndex(
|
const itemIndex = allItems().findIndex(
|
||||||
@@ -535,7 +587,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||||
}`}
|
}`}
|
||||||
data-picker-selected={itemIndex === selectedIndex()}
|
data-picker-selected={itemIndex === selectedIndex()}
|
||||||
onClick={() => handleSelect({ type: "file", file })}
|
onClick={() => props.onSelect({ type: "file", file }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export const commandMessages = {
|
|||||||
"unifiedPicker.sections.commands": "COMMANDS",
|
"unifiedPicker.sections.commands": "COMMANDS",
|
||||||
"unifiedPicker.sections.agents": "AGENTS",
|
"unifiedPicker.sections.agents": "AGENTS",
|
||||||
"unifiedPicker.sections.files": "FILES",
|
"unifiedPicker.sections.files": "FILES",
|
||||||
|
"unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT",
|
||||||
"unifiedPicker.badge.subagent": "subagent",
|
"unifiedPicker.badge.subagent": "subagent",
|
||||||
"unifiedPicker.footer.navigate": "navigate",
|
"unifiedPicker.footer.navigate": "navigate",
|
||||||
"unifiedPicker.footer.select": "select",
|
"unifiedPicker.footer.select": "select",
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
|
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
|
||||||
"instanceShell.rightDrawer.toggle.close": "Close 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.usedLabel": "Used",
|
||||||
"instanceShell.metrics.availableLabel": "Avail",
|
"instanceShell.metrics.availableLabel": "Avail",
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
|
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
|
||||||
"instanceShell.rightDrawer.toggle.close": "Cerrar 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.usedLabel": "Usado",
|
||||||
"instanceShell.metrics.availableLabel": "Disp.",
|
"instanceShell.metrics.availableLabel": "Disp.",
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
|
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
|
||||||
"instanceShell.rightDrawer.toggle.close": "Fermer 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.usedLabel": "Utilisé",
|
||||||
"instanceShell.metrics.availableLabel": "Dispo",
|
"instanceShell.metrics.availableLabel": "Dispo",
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightDrawer.toggle.open": "右ドロワーを開く",
|
"instanceShell.rightDrawer.toggle.open": "右ドロワーを開く",
|
||||||
"instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる",
|
"instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる",
|
||||||
|
|
||||||
|
"instanceShell.fullscreen.enter": "全画面",
|
||||||
|
"instanceShell.fullscreen.exit": "全画面を終了",
|
||||||
|
|
||||||
"instanceShell.metrics.usedLabel": "使用",
|
"instanceShell.metrics.usedLabel": "使用",
|
||||||
"instanceShell.metrics.availableLabel": "残り",
|
"instanceShell.metrics.availableLabel": "残り",
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightDrawer.toggle.open": "Открыть правую панель",
|
"instanceShell.rightDrawer.toggle.open": "Открыть правую панель",
|
||||||
"instanceShell.rightDrawer.toggle.close": "Закрыть правую панель",
|
"instanceShell.rightDrawer.toggle.close": "Закрыть правую панель",
|
||||||
|
|
||||||
|
"instanceShell.fullscreen.enter": "Полный экран",
|
||||||
|
"instanceShell.fullscreen.exit": "Выйти из полного экрана",
|
||||||
|
|
||||||
"instanceShell.metrics.usedLabel": "Использовано",
|
"instanceShell.metrics.usedLabel": "Использовано",
|
||||||
"instanceShell.metrics.availableLabel": "Доступно",
|
"instanceShell.metrics.availableLabel": "Доступно",
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightDrawer.toggle.open": "打开右侧抽屉",
|
"instanceShell.rightDrawer.toggle.open": "打开右侧抽屉",
|
||||||
"instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉",
|
"instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉",
|
||||||
|
|
||||||
|
"instanceShell.fullscreen.enter": "全屏",
|
||||||
|
"instanceShell.fullscreen.exit": "退出全屏",
|
||||||
|
|
||||||
"instanceShell.metrics.usedLabel": "已用",
|
"instanceShell.metrics.usedLabel": "已用",
|
||||||
"instanceShell.metrics.availableLabel": "可用",
|
"instanceShell.metrics.availableLabel": "可用",
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,59 @@
|
|||||||
import type { Attachment } from "../types/attachment"
|
import type { Attachment, FileSource } from "../types/attachment"
|
||||||
|
|
||||||
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
|
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
|
||||||
if (!prompt || !prompt.includes("[pasted #")) {
|
if (!prompt) {
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileAttachments = new Set(
|
||||||
|
attachments
|
||||||
|
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
|
||||||
|
.map((a) => a.source.path),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pathAttachments = new Set(
|
||||||
|
attachments
|
||||||
|
.filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:"))
|
||||||
|
.map((a) => (a.source as { value: string }).value),
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = prompt
|
||||||
|
|
||||||
|
// Step 1: Handle root paths FIRST using unique placeholders
|
||||||
|
// Replace longer pattern first to avoid partial match issues
|
||||||
|
result = result.replace(/@(\.\/)/g, "___ROOT___")
|
||||||
|
result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___")
|
||||||
|
// Note: The regex @(\.)(?!\.) means @. NOT followed by another .
|
||||||
|
|
||||||
|
// Step 2: Build set of non-root paths
|
||||||
|
const allPaths = new Set<string>()
|
||||||
|
for (const p of fileAttachments) {
|
||||||
|
if (p && p !== "." && p !== "./") allPaths.add(p)
|
||||||
|
}
|
||||||
|
for (const p of pathAttachments) {
|
||||||
|
if (p && p !== "." && p !== "./") allPaths.add(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Replace @path with ./path for non-root paths
|
||||||
|
for (const path of allPaths) {
|
||||||
|
if (!path) continue
|
||||||
|
const withoutPrefix = path.startsWith("./") ? path.slice(2) : path
|
||||||
|
const withPrefix = path.startsWith("./") ? path : "./" + path
|
||||||
|
result = result.replace("@" + withoutPrefix, withPrefix)
|
||||||
|
result = result.replace("@" + withoutPrefix + "/", withPrefix + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Convert placeholders back to ./
|
||||||
|
result = result.replace("___ROOT___", "./")
|
||||||
|
result = result.replace("___ROOT_NOSLASH___", "./")
|
||||||
|
|
||||||
|
// Step 5: Resolve [pasted #N] placeholders
|
||||||
|
if (!result.includes("[pasted #")) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
return prompt
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = new Map<string, string>()
|
const lookup = new Map<string, string>()
|
||||||
@@ -15,7 +62,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
|||||||
const source = attachment?.source
|
const source = attachment?.source
|
||||||
if (!source || source.type !== "text") continue
|
if (!source || source.type !== "text") continue
|
||||||
const display = attachment?.display
|
const display = attachment?.display
|
||||||
const value = source.value
|
const value = (source as { value?: string }).value
|
||||||
if (typeof display !== "string" || typeof value !== "string") continue
|
if (typeof display !== "string" || typeof value !== "string") continue
|
||||||
const match = display.match(/pasted #(\d+)/)
|
const match = display.match(/pasted #(\d+)/)
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
@@ -26,10 +73,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lookup.size === 0) {
|
if (lookup.size === 0) {
|
||||||
return prompt
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
|
return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
|
||||||
const replacement = lookup.get(fullMatch)
|
const replacement = lookup.get(fullMatch)
|
||||||
return typeof replacement === "string" ? replacement : fullMatch
|
return typeof replacement === "string" ? replacement : fullMatch
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,9 +63,14 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
|||||||
resolveSelectedModel(instanceProviders, latestProviderId, latestModelId)
|
resolveSelectedModel(instanceProviders, latestProviderId, latestModelId)
|
||||||
|
|
||||||
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
|
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
|
||||||
|
let modelInputLimit: number | null = null
|
||||||
|
|
||||||
if (selectedModel) {
|
if (selectedModel) {
|
||||||
contextWindow = selectedModel.limit?.context ?? 0
|
contextWindow = selectedModel.limit?.context ?? 0
|
||||||
|
const inputLimit = selectedModel.limit?.input
|
||||||
|
if (typeof inputLimit === "number" && inputLimit > 0) {
|
||||||
|
modelInputLimit = inputLimit
|
||||||
|
}
|
||||||
const outputLimit = selectedModel.limit?.output
|
const outputLimit = selectedModel.limit?.output
|
||||||
if (typeof outputLimit === "number" && outputLimit > 0) {
|
if (typeof outputLimit === "number" && outputLimit > 0) {
|
||||||
modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
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)
|
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 (contextWindow > 0) {
|
||||||
if (latestHasContextUsage && actualUsageTokens > 0) {
|
if (latestHasContextUsage && actualUsageTokens > 0) {
|
||||||
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 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))
|
void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setListeningMode(mode: ListeningMode): void {
|
async function setListeningMode(mode: ListeningMode): Promise<void> {
|
||||||
if (serverSettings().listeningMode === mode) return
|
if (serverSettings().listeningMode === mode) return
|
||||||
void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error))
|
await patchConfigOwner("server", { listeningMode: mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEnvironmentVariables(envVars: Record<string, string>): void {
|
function updateEnvironmentVariables(envVars: Record<string, string>): void {
|
||||||
void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) =>
|
void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) =>
|
||||||
|
|||||||
@@ -140,8 +140,11 @@ async function sendMessage(
|
|||||||
const display: string | undefined = att.display
|
const display: string | undefined = att.display
|
||||||
const value: unknown = source.value
|
const value: unknown = source.value
|
||||||
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
||||||
|
const isPathPlaceholder = typeof display === "string" && /^path:/.test(display)
|
||||||
|
|
||||||
if (isPastedPlaceholder || typeof value !== "string") {
|
// Skip path: attachments from being sent as separate parts (content is already in prompt)
|
||||||
|
// Skip pasted placeholders too (already resolved in prompt)
|
||||||
|
if (isPastedPlaceholder || isPathPlaceholder || typeof value !== "string") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -291,12 +291,13 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||||||
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
||||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||||
const initialContextWindow = initialModel?.limit?.context ?? 0
|
const initialContextWindow = initialModel?.limit?.context ?? 0
|
||||||
|
const initialInputLimit = initialModel?.limit?.input ?? 0
|
||||||
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
||||||
const initialOutputLimit =
|
const initialOutputLimit =
|
||||||
initialModel?.limit?.output && initialModel.limit.output > 0
|
initialModel?.limit?.output && initialModel.limit.output > 0
|
||||||
? initialModel.limit.output
|
? initialModel.limit.output
|
||||||
: DEFAULT_MODEL_OUTPUT_LIMIT
|
: DEFAULT_MODEL_OUTPUT_LIMIT
|
||||||
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
|
const initialContextAvailable = initialInputLimit > 0 ? initialInputLimit : initialContextWindow > 0 ? initialContextWindow : null
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
@@ -398,10 +399,11 @@ async function forkSession(
|
|||||||
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
||||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||||
const forkContextWindow = forkModel?.limit?.context ?? 0
|
const forkContextWindow = forkModel?.limit?.context ?? 0
|
||||||
|
const forkInputLimit = forkModel?.limit?.input ?? 0
|
||||||
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
||||||
const forkOutputLimit =
|
const forkOutputLimit =
|
||||||
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
|
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) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
|
|||||||
@@ -295,7 +295,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input {
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.prompt-input {
|
.prompt-input {
|
||||||
min-height: 64px;
|
min-height: 0;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
padding-bottom: 2.25rem;
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-wrapper {
|
.prompt-input-wrapper {
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-toolbar {
|
.file-viewer-toolbar {
|
||||||
@apply ml-auto flex items-center gap-1;
|
@apply flex items-center gap-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-toolbar-button {
|
.file-viewer-toolbar-button {
|
||||||
@@ -291,6 +291,22 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.file-viewer-toolbar-button:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
@@ -125,6 +125,18 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
width: 100%;
|
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 {
|
.session-resize-handle {
|
||||||
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface Model {
|
|||||||
variantKeys?: string[]
|
variantKeys?: string[]
|
||||||
limit?: {
|
limit?: {
|
||||||
context?: number
|
context?: number
|
||||||
|
input?: number
|
||||||
output?: number
|
output?: number
|
||||||
}
|
}
|
||||||
cost?: {
|
cost?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user