Compare commits
30 Commits
v0.9.3
...
jderehag/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6ed88be4 | ||
|
|
99474955af | ||
|
|
157fe9d6b4 | ||
|
|
6c42b64466 | ||
|
|
88605a4617 | ||
|
|
e8f8e7bd65 | ||
|
|
750a87ef45 | ||
|
|
8fda9aed71 | ||
|
|
7e1dab8384 | ||
|
|
5b24f0cd40 | ||
|
|
a6b1f4ba19 | ||
|
|
df02b7cdca | ||
|
|
06b0d03c31 | ||
|
|
fd22a5ed9d | ||
|
|
86db407c0b | ||
|
|
f1520be777 | ||
|
|
8a91e04ff9 | ||
|
|
76b1134c95 | ||
|
|
d98d519fd3 | ||
|
|
02407e0f7a | ||
|
|
0261154a5e | ||
|
|
d2b68159be | ||
|
|
aab0692403 | ||
|
|
17a3e43ac7 | ||
|
|
a2127a11ac | ||
|
|
ea4c687125 | ||
|
|
de20b3adf3 | ||
|
|
929e79befd | ||
|
|
3522d3dff5 | ||
|
|
1af01680ee |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Neural Nomads
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4635
package-lock.json
generated
4635
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/server",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@codenomad/ui-host-worker",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||
|
||||
@@ -505,7 +505,6 @@ app.on("before-quit", async (event) => {
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
// CodeNomad supports a single window; closing it should quit the app on all platforms.
|
||||
app.quit()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
@@ -109,11 +111,13 @@ export class CliProcessManager extends EventEmitter {
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
@@ -175,12 +179,89 @@ export class CliProcessManager extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
// Negative PID targets the process group (POSIX).
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryTaskkill = (force: boolean) => {
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("not found") || combined.includes("no running instance")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
|
||||
const groupOk = tryKillPosixGroup(signal)
|
||||
if (!groupOk) {
|
||||
tryKillSinglePid(signal)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
child.kill("SIGKILL")
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
@@ -191,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
if (isAlreadyExited()) {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
sendStopSignal("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,7 +294,16 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
const pid = this.child.pid
|
||||
if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "@codenomad/opencode-config",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.36"
|
||||
"@opencode-ai/plugin": "1.1.42"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ You can configure the server using flags or environment variables:
|
||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||
|
||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
|
||||
3. The app will open in a standalone window and appear in your OS app list.
|
||||
|
||||
> **TLS requirement**
|
||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||
> If you host CodeNomad on a remote machine, serve it behind a reverse proxy (e.g. Caddy, nginx) with a valid TLS certificate.
|
||||
> Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
|
||||
### Data Storage
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
|
||||
@@ -222,20 +222,18 @@ export class FileSystemBrowser {
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
if (!options.includeFiles && !entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
|
||||
// are treated as directories in directory-only listings.
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = entry.isDirectory()
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
|
||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
src/renderer/public/logo.png
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -29,11 +30,13 @@
|
||||
"solid-toast": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"autoprefixer": "10.4.21",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
|
||||
const highlighted = highlighter.codeToHtml(props.code, {
|
||||
lang: props.language as CodeToHtmlOptions["lang"],
|
||||
theme: isDark() ? "github-dark" : "github-light",
|
||||
theme: isDark() ? "github-dark" : "github-light-high-contrast",
|
||||
})
|
||||
setHtml(highlighted)
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
@@ -313,8 +314,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -322,8 +324,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -52,6 +53,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Accordion } from "@kobalte/core"
|
||||
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
import AppBar from "@suid/material/AppBar"
|
||||
import Box from "@suid/material/Box"
|
||||
import Divider from "@suid/material/Divider"
|
||||
import Drawer from "@suid/material/Drawer"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import Toolbar from "@suid/material/Toolbar"
|
||||
@@ -875,7 +874,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
@@ -911,11 +910,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
@@ -1087,8 +1087,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
|
||||
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base text-primary">
|
||||
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold text-primary">
|
||||
{t("instanceShell.rightPanel.title")}
|
||||
</Typography>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1330,13 +1330,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
@@ -1360,13 +1360,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
<Show when={!showingInfoView()}>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
@@ -72,6 +72,9 @@ export function Markdown(props: MarkdownProps) {
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
|
||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
@@ -171,6 +174,8 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { FoldVertical } from "lucide-solid"
|
||||
import { ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -11,6 +11,8 @@ import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
@@ -302,6 +304,7 @@ interface ToolCallItemProps {
|
||||
|
||||
function ToolCallItem(props: ToolCallItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
@@ -318,6 +321,14 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const deleteDisabled = createMemo(() => {
|
||||
if (deleting()) return true
|
||||
// Avoid deleting while a tool is actively running to prevent confusing UI states.
|
||||
if (isToolStateRunning(toolState())) return true
|
||||
// Avoid deleting permission prompts from here; those are interactive.
|
||||
return Boolean(toolPart()?.pendingPermission)
|
||||
})
|
||||
|
||||
const taskSessionId = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return ""
|
||||
@@ -341,6 +352,26 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
navigateToTaskSession(location)
|
||||
}
|
||||
|
||||
const handleDeleteToolPart = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (deleteDisabled()) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
|
||||
title: t("messageBlock.tool.deletePart.failed.title"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={toolPart()}>
|
||||
{(resolvedToolPart) => (
|
||||
@@ -351,17 +382,32 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
<span>{t("messageBlock.tool.header")}</span>
|
||||
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId()}>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={taskSessionId()}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation()}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={t("messageBlock.tool.goToSession.label")}
|
||||
aria-label={t("messageBlock.tool.goToSession.label")}
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation()}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation() ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
|
||||
disabled={deleteDisabled()}
|
||||
onClick={handleDeleteToolPart}
|
||||
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
>
|
||||
{t("messageBlock.tool.goToSession.label")}
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolCall
|
||||
@@ -395,6 +441,8 @@ type ReasoningDisplayItem = {
|
||||
messageInfo?: MessageInfo
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded: boolean
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
type CompactionDisplayItem = {
|
||||
@@ -403,6 +451,8 @@ type CompactionDisplayItem = {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
accentColor?: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
|
||||
@@ -530,7 +580,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushContent()
|
||||
const key = `${current.id}:${part.id ?? partIndex}:compaction`
|
||||
const partId = part.id ?? ""
|
||||
const key = `${current.id}:${partId || partIndex}:compaction`
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
items.push({
|
||||
type: "compaction",
|
||||
@@ -538,6 +589,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
part,
|
||||
messageInfo: info,
|
||||
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
|
||||
messageId: current.id,
|
||||
partId,
|
||||
})
|
||||
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
|
||||
return
|
||||
@@ -562,7 +615,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
if (part.type === "reasoning") {
|
||||
flushContent()
|
||||
if (props.showThinking() && reasoningHasRenderableContent(part)) {
|
||||
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
|
||||
const partId = part.id ?? ""
|
||||
const key = `${current.id}:${partId || partIndex}:reasoning`
|
||||
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
|
||||
if (showAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
@@ -574,6 +628,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
messageInfo: info,
|
||||
showAgentMeta,
|
||||
defaultExpanded: props.thinkingDefaultExpanded(),
|
||||
messageId: current.id,
|
||||
partId,
|
||||
})
|
||||
lastAccentColor = ASSISTANT_BORDER_COLOR
|
||||
}
|
||||
@@ -647,7 +703,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||
<StepCard
|
||||
kind="start"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
showAgentMeta
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<StepCard
|
||||
@@ -659,7 +720,15 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
|
||||
<CompactionCard
|
||||
part={(item as CompactionDisplayItem).part}
|
||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as CompactionDisplayItem).messageId}
|
||||
partId={(item as CompactionDisplayItem).partId}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
@@ -667,6 +736,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as ReasoningDisplayItem).messageId}
|
||||
partId={(item as ReasoningDisplayItem).partId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
/>
|
||||
@@ -689,8 +760,19 @@ interface StepCardProps {
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
||||
interface CompactionCardProps {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
borderColor?: string
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: CompactionCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
|
||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||
@@ -698,13 +780,43 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
|
||||
const containerClass = () =>
|
||||
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
||||
|
||||
const canDelete = () => Boolean(props.partId) && !deleting()
|
||||
|
||||
const handleDelete = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={containerClass()}
|
||||
class={`${containerClass()} relative`}
|
||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||
role="status"
|
||||
aria-label={t("messageBlock.compaction.ariaLabel")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
|
||||
disabled={!canDelete()}
|
||||
onClick={handleDelete}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</button>
|
||||
|
||||
<div class="message-compaction-row">
|
||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||
<span class="message-compaction-label">{label()}</span>
|
||||
@@ -759,6 +871,7 @@ function StepCard(props: StepCardProps) {
|
||||
|
||||
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
||||
|
||||
|
||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||
const entries = [
|
||||
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
|
||||
@@ -824,6 +937,8 @@ interface ReasoningCardProps {
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
@@ -831,6 +946,7 @@ interface ReasoningCardProps {
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
@@ -894,6 +1010,27 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
const toggle = () => setExpanded((prev) => !prev)
|
||||
|
||||
const hasDeleteTarget = () => Boolean(props.partId)
|
||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
||||
|
||||
const handleDelete = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-reasoning-card">
|
||||
<button
|
||||
@@ -924,6 +1061,25 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<span class="message-reasoning-indicator">
|
||||
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
|
||||
</span>
|
||||
|
||||
<Show when={hasDeleteTarget()}>
|
||||
<span
|
||||
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDelete}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
handleDelete(event)
|
||||
}
|
||||
}}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<span class="message-reasoning-time">{timestamp()}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Copy, Split, Trash2, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -22,6 +25,7 @@ interface MessageItemProps {
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
@@ -156,6 +160,8 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const copyLabel = () => (copied() ? t("messageItem.actions.copied") : t("messageItem.actions.copy"))
|
||||
|
||||
const getRawContent = () => {
|
||||
return props.parts
|
||||
.filter(part => part.type === "text")
|
||||
@@ -172,6 +178,50 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const deletableTextPartId = () => {
|
||||
const part = props.parts.find((candidate) => {
|
||||
if (!candidate || candidate.type !== "text") return false
|
||||
const id = (candidate as any).id
|
||||
if (typeof id !== "string" || id.length === 0) return false
|
||||
return !Boolean((candidate as any).synthetic)
|
||||
})
|
||||
return (part as any)?.id as string | undefined
|
||||
}
|
||||
|
||||
const isDeletingPart = (partId?: string) => {
|
||||
if (!partId) return false
|
||||
return deletingParts().has(partId)
|
||||
}
|
||||
|
||||
const setPartDeleting = (partId: string, value: boolean) => {
|
||||
setDeletingParts((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) {
|
||||
next.add(partId)
|
||||
} else {
|
||||
next.delete(partId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePart = async (partId?: string) => {
|
||||
if (!partId) return
|
||||
if (isDeletingPart(partId)) return
|
||||
setPartDeleting(partId, true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setPartDeleting(partId, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
||||
return null
|
||||
}
|
||||
@@ -231,45 +281,70 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revertTitle")}
|
||||
aria-label={t("messageItem.actions.revertTitle")}
|
||||
title={t("messageItem.actions.revert")}
|
||||
aria-label={t("messageItem.actions.revert")}
|
||||
>
|
||||
{t("messageItem.actions.revert")}
|
||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title={t("messageItem.actions.forkTitle")}
|
||||
aria-label={t("messageItem.actions.forkTitle")}
|
||||
title={t("messageItem.actions.fork")}
|
||||
aria-label={t("messageItem.actions.fork")}
|
||||
>
|
||||
{t("messageItem.actions.fork")}
|
||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={t("messageItem.actions.copyTitle")}
|
||||
aria-label={t("messageItem.actions.copyTitle")}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||
{t("messageItem.actions.copied")}
|
||||
</Show>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={t("messageItem.actions.copyTitle")}
|
||||
aria-label={t("messageItem.actions.copyTitle")}
|
||||
>
|
||||
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||
{t("messageItem.actions.copied")}
|
||||
<div class="message-action-group">
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
@@ -337,6 +412,19 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeletePart(attachment.id)}
|
||||
class="attachment-remove"
|
||||
disabled={isDeletingPart(attachment.id)}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import Kbd from "./kbd"
|
||||
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-primary/70"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MessagePartProps {
|
||||
sessionId: string
|
||||
onRendered?: () => void
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
@@ -32,6 +32,7 @@ interface MessagePartProps {
|
||||
return Boolean((part as any).synthetic) && props.messageType !== "user"
|
||||
}
|
||||
|
||||
|
||||
const plainTextContent = () => {
|
||||
const part = props.part
|
||||
|
||||
@@ -103,21 +104,21 @@ interface MessagePartProps {
|
||||
<Match when={partType() === "text"}>
|
||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||
<div class={textContainerClass()}>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span>{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span class="text-primary">{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getSessionInfo } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
@@ -375,7 +377,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
||||
const shellRect = shell.getBoundingClientRect()
|
||||
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
||||
const maxLeft = Math.max(shell.clientWidth - 180, 8)
|
||||
// Keep the popover within the stream shell. The quote popover currently
|
||||
// renders 3 actions; keep enough horizontal room for the pill.
|
||||
const maxLeft = Math.max(shell.clientWidth - 260, 8)
|
||||
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
||||
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
||||
}
|
||||
@@ -394,6 +398,24 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopySelectionRequest() {
|
||||
const info = quoteSelection()
|
||||
if (!info) return
|
||||
|
||||
const success = await copyToClipboard(info.text)
|
||||
showToastNotification({
|
||||
message: success ? t("messageSection.quote.copied") : t("messageSection.quote.copyFailed"),
|
||||
variant: success ? "success" : "error",
|
||||
duration: success ? 2000 : 6000,
|
||||
})
|
||||
|
||||
clearQuoteSelection()
|
||||
if (typeof window !== "undefined") {
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (props.loading) {
|
||||
@@ -835,6 +857,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||
{t("messageSection.quote.addAsCode")}
|
||||
</button>
|
||||
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
|
||||
{t("messageSection.quote.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1021,7 +1021,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||
if (!blockquote) return
|
||||
|
||||
insertBlockContent(`${blockquote}\n\n`)
|
||||
insertBlockContent(`${blockquote}\n`)
|
||||
}
|
||||
|
||||
function insertCodeSelection(rawText: string) {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import {
|
||||
deleteSession,
|
||||
ensureSessionParentExpanded,
|
||||
@@ -35,6 +36,7 @@ interface SessionListProps {
|
||||
showFooter?: boolean
|
||||
headerContent?: JSX.Element
|
||||
footerContent?: JSX.Element
|
||||
enableFilterBar?: boolean
|
||||
}
|
||||
|
||||
function formatSessionStatus(status: SessionStatus): string {
|
||||
@@ -46,6 +48,70 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
|
||||
const [filterQuery, setFilterQuery] = createSignal("")
|
||||
const normalizedQuery = createMemo(() => filterQuery().trim().toLowerCase())
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const title = (session?.title ?? "").trim()
|
||||
return title || t("sessionList.session.untitled")
|
||||
}
|
||||
|
||||
const sessionMatchesQuery = (sessionId: string, query: string) => {
|
||||
if (!query) return true
|
||||
const label = normalizeSessionLabel(sessionId).toLowerCase()
|
||||
if (label.includes(query)) return true
|
||||
return sessionId.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
const filteredThreads = createMemo<SessionThread[]>(() => {
|
||||
const query = normalizedQuery()
|
||||
if (!query) return props.threads
|
||||
|
||||
const next: SessionThread[] = []
|
||||
for (const thread of props.threads) {
|
||||
const parentMatches = sessionMatchesQuery(thread.parent.id, query)
|
||||
const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query))
|
||||
|
||||
if (!parentMatches && matchingChildren.length === 0) continue
|
||||
|
||||
next.push({
|
||||
parent: thread.parent,
|
||||
children: matchingChildren,
|
||||
latestUpdated: thread.latestUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
const allMatchingSessionIds = createMemo<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
for (const thread of filteredThreads()) {
|
||||
ids.push(thread.parent.id)
|
||||
for (const child of thread.children) ids.push(child.id)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const selectedCount = createMemo(() => selectedSessionIds().size)
|
||||
|
||||
const isAllSelected = createMemo(() => {
|
||||
const ids = allMatchingSessionIds()
|
||||
if (ids.length === 0) return false
|
||||
const selected = selectedSessionIds()
|
||||
return ids.every((id) => selected.has(id))
|
||||
})
|
||||
const isSelectAllIndeterminate = createMemo(() => {
|
||||
const ids = allMatchingSessionIds()
|
||||
const total = ids.length
|
||||
if (total === 0) return false
|
||||
const count = selectedCount()
|
||||
return count > 0 && count < total
|
||||
})
|
||||
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instanceId)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
@@ -54,9 +120,10 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
// If the user selects a child session, make sure its parent thread is expanded.
|
||||
// For parent sessions we don't force expansion; user can collapse/expand freely.
|
||||
if (session?.parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, session.parentId)
|
||||
}
|
||||
|
||||
props.onSelect(sessionId)
|
||||
@@ -82,6 +149,17 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
t("sessionList.delete.confirmMessage", { label: normalizeSessionLabel(sessionId) }),
|
||||
{
|
||||
title: t("sessionList.delete.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||
},
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
const shouldSelectFallback = props.activeSessionId === sessionId
|
||||
let fallbackSessionId: string | undefined
|
||||
|
||||
@@ -152,6 +230,115 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedMany = (sessionIds: string[], checked: boolean) => {
|
||||
if (sessionIds.length === 0) return
|
||||
setSelectedSessionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
sessionIds.forEach((id) => {
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getSelectableThreadIds = (parentId: string): string[] => {
|
||||
const query = normalizedQuery()
|
||||
const source = query ? filteredThreads() : props.threads
|
||||
const thread = source.find((t) => t.parent.id === parentId)
|
||||
if (!thread) return [parentId]
|
||||
return [thread.parent.id, ...thread.children.map((c) => c.id)]
|
||||
}
|
||||
|
||||
const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => {
|
||||
const ids: string[] = []
|
||||
threads.forEach((thread) => {
|
||||
ids.push(thread.parent.id)
|
||||
thread.children.forEach((child) => ids.push(child.id))
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
const handleToggleSelectAll = (checked: boolean) => {
|
||||
const ids = allMatchingSessionIds()
|
||||
setSelectedMany(ids, checked)
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected()) {
|
||||
handleToggleSelectAll(false)
|
||||
return
|
||||
}
|
||||
handleToggleSelectAll(true)
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const selected = Array.from(selectedSessionIds())
|
||||
if (selected.length === 0) return
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
t("sessionList.bulkDelete.confirmMessage", { count: selected.length }),
|
||||
{
|
||||
title: t("sessionList.bulkDelete.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||
},
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
const deletedSet = new Set(selected)
|
||||
const currentActiveId = props.activeSessionId
|
||||
|
||||
let fallbackSessionId: string | undefined
|
||||
if (currentActiveId && deletedSet.has(currentActiveId)) {
|
||||
const ordered = getAllSessionIdsInOrder(props.threads)
|
||||
const currentIndex = ordered.indexOf(currentActiveId)
|
||||
|
||||
for (let i = Math.max(0, currentIndex); i < ordered.length; i++) {
|
||||
const candidate = ordered[i]
|
||||
if (candidate && !deletedSet.has(candidate)) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!fallbackSessionId) {
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const candidate = ordered[i]
|
||||
if (candidate && !deletedSet.has(candidate)) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed = 0
|
||||
for (const sessionId of selected) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteSession(props.instanceId, sessionId)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedSessionIds(new Set<string>())
|
||||
|
||||
if (fallbackSessionId) {
|
||||
setActiveSessionFromList(props.instanceId, fallbackSessionId)
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
showToastNotification({
|
||||
message: t("sessionList.bulkDelete.error", { count: failed }),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const SessionRow: Component<{
|
||||
@@ -190,9 +377,31 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
? t("sessionList.status.needsInput")
|
||||
: statusLabel()
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||
|
||||
const parentGroupState = createMemo(() => {
|
||||
if (rowProps.isChild) {
|
||||
return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] }
|
||||
}
|
||||
|
||||
const ids = getSelectableThreadIds(rowProps.sessionId)
|
||||
const selected = selectedSessionIds()
|
||||
const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0)
|
||||
return {
|
||||
checked: selectedInGroup > 0 && selectedInGroup === ids.length,
|
||||
indeterminate: selectedInGroup > 0 && selectedInGroup < ids.length,
|
||||
ids,
|
||||
}
|
||||
})
|
||||
|
||||
let rowCheckboxEl: HTMLInputElement | null = null
|
||||
createEffect(() => {
|
||||
if (!rowCheckboxEl) return
|
||||
rowCheckboxEl.indeterminate = parentGroupState().indeterminate
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
<button
|
||||
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
data-session-id={rowProps.sessionId}
|
||||
@@ -204,11 +413,23 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
{rowProps.isChild ? (
|
||||
<Bot class="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<User class="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<Show when={props.enableFilterBar}>
|
||||
<input
|
||||
ref={(el) => {
|
||||
rowCheckboxEl = el
|
||||
}}
|
||||
type="checkbox"
|
||||
checked={parentGroupState().checked}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
setSelectedMany(parentGroupState().ids, event.currentTarget.checked)
|
||||
}}
|
||||
aria-label={t("sessionList.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
||||
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,9 +437,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={rowProps.hasChildren && !rowProps.isChild}
|
||||
fallback={
|
||||
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
|
||||
}
|
||||
fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
|
||||
>
|
||||
<span
|
||||
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
@@ -228,18 +447,16 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")}
|
||||
aria-label={
|
||||
rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")
|
||||
}
|
||||
title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
|
||||
>
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{needsInput() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{statusText()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -309,6 +526,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Keep the active child session visible by ensuring its parent is expanded.
|
||||
// Don't force-expanding when the active session itself is a parent lets users collapse it.
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return
|
||||
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||
if (!activeSession) return
|
||||
if (!activeSession.parentId) return
|
||||
const parentId = activeParentId()
|
||||
if (!parentId) return
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
@@ -365,6 +589,63 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||
>
|
||||
<Show when={props.enableFilterBar}>
|
||||
<div class="p-3 border-b border-base">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true">
|
||||
<Search class="w-4 h-4" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input pl-9"
|
||||
value={filterQuery()}
|
||||
onInput={(e) => setFilterQuery(e.currentTarget.value)}
|
||||
placeholder={t("sessionList.filter.placeholder")}
|
||||
aria-label={t("sessionList.filter.ariaLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary p-2 inline-flex items-center justify-center"
|
||||
onClick={toggleSelectAll}
|
||||
disabled={allMatchingSessionIds().length === 0}
|
||||
aria-label={t("sessionList.selection.selectAllAriaLabel")}
|
||||
title={t("sessionList.selection.selectAllLabel")}
|
||||
>
|
||||
<Show
|
||||
when={isSelectAllIndeterminate()}
|
||||
fallback={isAllSelected() ? <CheckSquare class="w-4 h-4" /> : <Square class="w-4 h-4" />}
|
||||
>
|
||||
<MinusSquare class="w-4 h-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={selectedCount() > 0}>
|
||||
<div class="mt-2 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={handleBulkDelete}
|
||||
aria-label={t("sessionList.bulkDelete.ariaLabel", { count: selectedCount() })}
|
||||
>
|
||||
{t("sessionList.bulkDelete.button", { count: selectedCount() })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={() => setSelectedSessionIds(new Set<string>())}
|
||||
aria-label={t("sessionList.selection.clearAriaLabel")}
|
||||
>
|
||||
{t("sessionList.selection.clearLabel")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showHeader !== false}>
|
||||
<div class="session-list-header p-3 border-b border-base">
|
||||
{props.headerContent ?? (
|
||||
@@ -378,33 +659,33 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
|
||||
<Show when={props.threads.length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={props.threads}>
|
||||
<Show when={filteredThreads().length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={filteredThreads()}>
|
||||
|
||||
{(thread) => {
|
||||
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
{(thread) => {
|
||||
const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -9,8 +9,8 @@ interface ContextUsagePanelProps {
|
||||
}
|
||||
|
||||
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
|
||||
const headingClass = "text-xs font-semibold text-muted uppercase tracking-wide"
|
||||
|
||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
@@ -49,7 +49,7 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
||||
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
|
||||
@@ -65,7 +65,7 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
||||
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
|
||||
import { Show, For, createMemo, createEffect, on, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
@@ -112,6 +112,43 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
if (!props.isActive) return
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isActive,
|
||||
(isActive) => {
|
||||
if (!isActive) return
|
||||
|
||||
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
|
||||
if (typeof document === "undefined") return
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
const activeIsInput =
|
||||
activeEl?.tagName === "INPUT" ||
|
||||
activeEl?.tagName === "TEXTAREA" ||
|
||||
activeEl?.tagName === "SELECT" ||
|
||||
Boolean(activeEl?.isContentEditable)
|
||||
if (activeIsInput) return
|
||||
|
||||
const modalOpen = Boolean(document.querySelector('[role="dialog"][aria-modal="true"]'))
|
||||
if (modalOpen) return
|
||||
|
||||
// Defer until the session pane is visible and the textarea is mounted.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
|
||||
if (!textarea) return
|
||||
if (textarea.disabled) return
|
||||
|
||||
try {
|
||||
textarea.focus({ preventScroll: true } as any)
|
||||
} catch {
|
||||
textarea.focus()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
39
packages/ui/src/components/theme-mode-toggle.tsx
Normal file
39
packages/ui/src/components/theme-mode-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { Laptop, Moon, Sun } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { useTheme } from "../lib/theme"
|
||||
|
||||
interface ThemeModeToggleProps {
|
||||
class?: string
|
||||
}
|
||||
|
||||
export const ThemeModeToggle: Component<ThemeModeToggleProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { themeMode, cycleThemeMode } = useTheme()
|
||||
|
||||
const modeLabel = () => {
|
||||
const mode = themeMode()
|
||||
if (mode === "system") return t("theme.mode.system")
|
||||
if (mode === "light") return t("theme.mode.light")
|
||||
return t("theme.mode.dark")
|
||||
}
|
||||
|
||||
const icon = createMemo(() => {
|
||||
const mode = themeMode()
|
||||
if (mode === "system") return <Laptop class="w-4 h-4" />
|
||||
if (mode === "light") return <Sun class="w-4 h-4" />
|
||||
return <Moon class="w-4 h-4" />
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={props.class ?? "new-tab-button"}
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={t("theme.toggle.ariaLabel", { mode: modeLabel() })}
|
||||
title={t("theme.toggle.title", { mode: modeLabel() })}
|
||||
>
|
||||
{icon()}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { Copy } from "lucide-solid"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionSessionId } from "../types/permission"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
@@ -59,6 +61,11 @@ interface ToolCallProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onContentRendered?: () => void
|
||||
/**
|
||||
* When true, tool call starts collapsed regardless of user preferences.
|
||||
* Users can still expand/collapse manually.
|
||||
*/
|
||||
forceCollapsed?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +149,9 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
|
||||
const defaultExpandedForTool = createMemo(() => {
|
||||
if (props.forceCollapsed) {
|
||||
return false
|
||||
}
|
||||
const prefExpanded = toolOutputDefaultExpanded()
|
||||
const toolName = toolCallMemo()?.tool || ""
|
||||
if (toolName === "read") {
|
||||
@@ -575,12 +585,29 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
toolCall: toolCallMemo,
|
||||
toolState,
|
||||
toolName,
|
||||
instanceId: props.instanceId,
|
||||
sessionId: props.sessionId,
|
||||
t,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown: renderMarkdownContent,
|
||||
renderAnsi: renderAnsiContent,
|
||||
renderDiff: renderDiffContent,
|
||||
renderToolCall: (options) => {
|
||||
if (!options?.toolCall) return null
|
||||
return (
|
||||
<ToolCall
|
||||
toolCall={options.toolCall}
|
||||
toolCallId={options.toolCall.id}
|
||||
messageId={options.messageId}
|
||||
messageVersion={options.messageVersion}
|
||||
partVersion={options.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={options.sessionId}
|
||||
forceCollapsed={options.forceCollapsed}
|
||||
/>
|
||||
)
|
||||
},
|
||||
scrollHelpers,
|
||||
}
|
||||
|
||||
@@ -634,6 +661,19 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return getToolName(currentTool)
|
||||
}
|
||||
|
||||
const headerText = createMemo(() => {
|
||||
// Keep this as a memo so copy always matches what's rendered.
|
||||
return renderToolTitle()
|
||||
})
|
||||
|
||||
const handleCopyHeader = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const text = headerText()
|
||||
if (!text) return
|
||||
await copyToClipboard(text)
|
||||
}
|
||||
|
||||
const renderToolBody = () => {
|
||||
return renderer().renderBody(rendererContext)
|
||||
}
|
||||
@@ -737,16 +777,32 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}}
|
||||
class={`tool-call ${combinedStatusClass()}`}
|
||||
>
|
||||
<button
|
||||
class="tool-call-header"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
data-status-icon={statusIcon()}
|
||||
>
|
||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||
{renderToolTitle()}
|
||||
<div class="tool-call-header">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
>
|
||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||
{headerText()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-copy"
|
||||
onClick={handleCopyHeader}
|
||||
aria-label={t("toolCall.header.copyAriaLabel")}
|
||||
title={t("toolCall.header.copyTitle")}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span class="tool-call-header-status" aria-hidden="true">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded() && (
|
||||
<div class="tool-call-details">
|
||||
|
||||
@@ -80,6 +80,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
return Array.isArray(draft) ? draft : []
|
||||
})
|
||||
|
||||
const hasFinalAnswers = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
if ((state as any)?.status === "completed") return true
|
||||
|
||||
const request = props.request()
|
||||
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer)
|
||||
if (Array.isArray(requestAnswers) && requestAnswers.length > 0) {
|
||||
return requestAnswers.every((row) => Array.isArray(row) && row.length > 0)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||
if (!props.active()) return
|
||||
props.setDraftAnswers((prev) => {
|
||||
@@ -170,22 +183,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
|
||||
return (
|
||||
<Show when={isVisible() && questions().length > 0}>
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">
|
||||
{props.active()
|
||||
? t("toolCall.question.status.required")
|
||||
: props.request()
|
||||
? t("toolCall.question.status.queued")
|
||||
: t("toolCall.question.status.questions")}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">
|
||||
{questions().length === 1 ? t("toolCall.question.type.one") : t("toolCall.question.type.other")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
|
||||
>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const i = () => index()
|
||||
@@ -199,9 +201,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
const customChecked = () => customValue().length > 0
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||
<div class="border border-base bg-surface-secondary p-3 text-primary">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs">
|
||||
<div class="text-sm text-primary">
|
||||
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
@@ -209,7 +211,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||
<div class="mt-1 text-sm font-medium text-primary">{q?.question}</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<For each={q?.options ?? []}>
|
||||
@@ -226,12 +228,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||
checked={checked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={() => toggleOption(i(), opt.label)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight">{opt.label}</div>
|
||||
<div class="text-sm leading-tight text-primary">{opt.label}</div>
|
||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -249,6 +252,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||
checked={customChecked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={(e) => {
|
||||
@@ -266,13 +270,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="text-sm leading-tight">{t("toolCall.question.custom.label")}</div>
|
||||
<div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
|
||||
<input
|
||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||
class="w-full rounded-md border border-base bg-surface-base px-2 py-1 text-sm text-primary"
|
||||
type="text"
|
||||
placeholder={t("toolCall.question.custom.placeholder")}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
onFocus={(e) => {
|
||||
if (!props.active()) return
|
||||
// Keep the radio/checkbox selected while editing.
|
||||
@@ -296,7 +300,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</For>
|
||||
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-actions px-3 pb-3">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -330,7 +334,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={!props.active() && props.request()}>
|
||||
<p class="tool-call-permission-queued-text">{t("toolCall.question.queuedText")}</p>
|
||||
<p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||
import { loadMessages } from "../../../stores/session-api"
|
||||
import { loading, messagesLoaded } from "../../../stores/session-state"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
id: string
|
||||
@@ -14,6 +17,70 @@ interface TaskSummaryItem {
|
||||
title?: string
|
||||
}
|
||||
|
||||
function extractSessionIdFromTaskState(state?: ToolState): string {
|
||||
if (!state) return ""
|
||||
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
|
||||
const directId = (metadata as any)?.sessionId ?? (metadata as any)?.sessionID
|
||||
return typeof directId === "string" ? directId : ""
|
||||
}
|
||||
|
||||
function splitToolKey(key: string): { messageId: string; partId: string } | null {
|
||||
const separator = "::"
|
||||
const index = key.lastIndexOf(separator)
|
||||
if (index <= 0) return null
|
||||
const messageId = key.slice(0, index)
|
||||
const partId = key.slice(index + separator.length)
|
||||
if (!messageId || !partId) return null
|
||||
return { messageId, partId }
|
||||
}
|
||||
|
||||
function TaskToolCallRow(props: {
|
||||
toolKey: string
|
||||
store: ReturnType<typeof messageStoreBus.getOrCreate>
|
||||
sessionId: string
|
||||
renderToolCall: NonNullable<import("../types").ToolRendererContext["renderToolCall"]>
|
||||
}) {
|
||||
const parts = createMemo(() => splitToolKey(props.toolKey))
|
||||
const messageId = createMemo(() => parts()?.messageId ?? "")
|
||||
const partId = createMemo(() => parts()?.partId ?? "")
|
||||
|
||||
const record = createMemo(() => {
|
||||
const id = messageId()
|
||||
if (!id) return undefined
|
||||
return props.store.getMessage(id)
|
||||
})
|
||||
|
||||
const partEntry = createMemo(() => {
|
||||
const rec = record()
|
||||
const pid = partId()
|
||||
if (!rec || !pid) return undefined
|
||||
return rec.parts?.[pid]
|
||||
})
|
||||
|
||||
const toolPart = createMemo(() => {
|
||||
const data = partEntry()?.data
|
||||
return data && (data as any).type === "tool" ? (data as any) : undefined
|
||||
})
|
||||
|
||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const rendered = createMemo(() => {
|
||||
const part = toolPart()
|
||||
if (!part) return null
|
||||
return props.renderToolCall({
|
||||
toolCall: part as any,
|
||||
messageId: messageId(),
|
||||
messageVersion: messageVersion(),
|
||||
partVersion: partVersion(),
|
||||
sessionId: props.sessionId,
|
||||
forceCollapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
return <>{rendered()}</>
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||
return status
|
||||
@@ -78,7 +145,63 @@ export const taskRenderer: ToolRenderer = {
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||
|
||||
const childSessionId = createMemo(() => {
|
||||
const state = toolState()
|
||||
return extractSessionIdFromTaskState(state)
|
||||
})
|
||||
|
||||
const childSessionLoaded = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return false
|
||||
const loadedForInstance = messagesLoaded().get(instanceId)
|
||||
return loadedForInstance?.has(id) ?? false
|
||||
})
|
||||
|
||||
const childSessionLoading = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return false
|
||||
const loadingSet = loading().loadingMessages.get(instanceId)
|
||||
return loadingSet?.has(id) ?? false
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return
|
||||
if (requestedChildLoad()) return
|
||||
if (childSessionLoaded()) return
|
||||
if (childSessionLoading()) return
|
||||
setRequestedChildLoad(true)
|
||||
void loadMessages(instanceId, id)
|
||||
})
|
||||
|
||||
const childToolKeys = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return [] as string[]
|
||||
if (!childSessionLoaded()) return [] as string[]
|
||||
|
||||
// React to session changes, but do the scan untracked to avoid
|
||||
// subscribing to every message/part node in the store.
|
||||
store.getSessionRevision(id)
|
||||
return untrack(() => {
|
||||
const messageIds = store.getSessionMessageIds(id)
|
||||
const keys: string[] = []
|
||||
for (const messageId of messageIds) {
|
||||
const record = store.getMessage(messageId)
|
||||
if (!record) continue
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts?.[partId]
|
||||
const data = entry?.data
|
||||
if (!data || (data as any).type !== "tool") continue
|
||||
keys.push(`${messageId}::${partId}`)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
})
|
||||
})
|
||||
const promptContent = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
@@ -123,7 +246,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
return null
|
||||
})
|
||||
|
||||
const items = createMemo(() => {
|
||||
const legacyItems = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
partVersion?.()
|
||||
@@ -131,6 +254,9 @@ export const taskRenderer: ToolRenderer = {
|
||||
const state = toolState()
|
||||
if (!state) return []
|
||||
|
||||
// Prefer deriving steps from the child session when loaded.
|
||||
if (childSessionLoaded()) return []
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
||||
|
||||
@@ -167,51 +293,84 @@ export const taskRenderer: ToolRenderer = {
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={items().length > 0}>
|
||||
<Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
|
||||
<span class="tool-call-task-section-meta">{t("toolCall.task.steps.count", { count: items().length })}</span>
|
||||
<span class="tool-call-task-section-meta">
|
||||
{t("toolCall.task.steps.count", { count: childToolKeys().length > 0 ? childToolKeys().length : legacyItems().length })}
|
||||
</span>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
<Show
|
||||
when={childToolKeys().length > 0}
|
||||
fallback={
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={legacyItems()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusKey = summarizeStatusLabel(status)
|
||||
const statusLabel = statusKey
|
||||
? t(`toolCall.status.${statusKey}`)
|
||||
: t("toolCall.status.unknown")
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusKey = summarizeStatusLabel(status)
|
||||
const statusLabel = statusKey
|
||||
? t(`toolCall.status.${statusKey}`)
|
||||
: t("toolCall.status.unknown")
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
@@ -74,12 +74,15 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||
toolCall: toolCallAccessor,
|
||||
toolState: toolStateAccessor,
|
||||
toolName: toolNameAccessor,
|
||||
instanceId: "",
|
||||
sessionId: "",
|
||||
t,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown,
|
||||
renderAnsi,
|
||||
renderDiff,
|
||||
renderToolCall: () => null,
|
||||
scrollHelpers: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,26 @@ export interface ToolRendererContext {
|
||||
toolCall: Accessor<ToolCallPart>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolName: Accessor<string>
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
messageVersion?: Accessor<number | undefined>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
|
||||
/**
|
||||
* Render another tool call inline. This is provided by the ToolCall shell
|
||||
* to avoid renderer-level imports that would create cyclic dependencies.
|
||||
*/
|
||||
renderToolCall?: (options: {
|
||||
toolCall: ToolCallPart
|
||||
messageId?: string
|
||||
messageVersion?: number
|
||||
partVersion?: number
|
||||
sessionId: string
|
||||
forceCollapsed?: boolean
|
||||
}) => JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}
|
||||
|
||||
|
||||
@@ -29,4 +29,10 @@ export const appMessages = {
|
||||
|
||||
"releases.uiUpdated.title": "UI updated",
|
||||
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
||||
|
||||
"theme.mode.system": "System",
|
||||
"theme.mode.light": "Light",
|
||||
"theme.mode.dark": "Dark",
|
||||
"theme.toggle.title": "Theme: {mode}",
|
||||
"theme.toggle.ariaLabel": "Theme: {mode}",
|
||||
} as const
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||
"messageSection.quote.addAsQuote": "Add as quote",
|
||||
"messageSection.quote.addAsCode": "Add as code",
|
||||
"messageSection.quote.copy": "Copy",
|
||||
"messageSection.quote.copied": "Copied!",
|
||||
"messageSection.quote.copyFailed": "Copy failed",
|
||||
|
||||
"messageTimeline.ariaLabel": "Message timeline",
|
||||
"messageTimeline.segment.user.label": "You",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Go to Session",
|
||||
"messageBlock.tool.goToSession.title": "Go to session",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
|
||||
"messageBlock.tool.deletePart.label": "Delete",
|
||||
"messageBlock.tool.deletePart.deleting": "Deleting...",
|
||||
"messageBlock.tool.deletePart.title": "Delete this tool call output",
|
||||
"messageBlock.tool.deletePart.failed.title": "Delete failed",
|
||||
"messageBlock.tool.deletePart.failed.message": "Failed to delete tool call output",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Session compaction",
|
||||
"messageBlock.compaction.autoLabel": "Session auto-compacted",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Generating...",
|
||||
"messageItem.status.sending": "Sending...",
|
||||
"messageItem.status.failedToSend": "Message failed to send",
|
||||
"messagePart.actions.delete": "Delete",
|
||||
"messagePart.actions.deleting": "Deleting...",
|
||||
"messagePart.actions.deleteTitle": "Delete this item",
|
||||
"messagePart.actions.deleteFailedTitle": "Delete failed",
|
||||
"messagePart.actions.deleteFailedMessage": "Failed to delete item",
|
||||
"messageItem.attachment.defaultName": "attachment",
|
||||
"messageItem.attachment.downloadAriaLabel": "Download {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Agent: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "Session ID copied",
|
||||
"sessionList.copyId.error": "Unable to copy session ID",
|
||||
"sessionList.delete.error": "Unable to delete session",
|
||||
"sessionList.delete.title": "Delete session",
|
||||
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",
|
||||
"sessionList.delete.confirmLabel": "Delete",
|
||||
"sessionList.delete.cancelLabel": "Cancel",
|
||||
"sessionList.rename.error": "Unable to rename session",
|
||||
|
||||
"sessionList.filter.placeholder": "Search sessions…",
|
||||
"sessionList.filter.ariaLabel": "Search sessions",
|
||||
"sessionList.selection.selectAllLabel": "Select all",
|
||||
"sessionList.selection.selectAllAriaLabel": "Select all sessions",
|
||||
"sessionList.selection.clearLabel": "Clear",
|
||||
"sessionList.selection.clearAriaLabel": "Clear selection",
|
||||
"sessionList.selection.checkboxAriaLabel": "Select session",
|
||||
"sessionList.bulkDelete.button": "Delete {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Delete {count} selected sessions",
|
||||
"sessionList.bulkDelete.title": "Delete sessions",
|
||||
"sessionList.bulkDelete.confirmMessage": "Delete {count} selected sessions? This cannot be undone.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Delete",
|
||||
"sessionList.bulkDelete.cancelLabel": "Cancel",
|
||||
"sessionList.bulkDelete.error": "Unable to delete {count} sessions",
|
||||
|
||||
"sessionRenameDialog.title": "Rename Session",
|
||||
"sessionRenameDialog.description.withLabel": "Update the title for \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Set a new title for this session.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Waiting to run...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||
"messageSection.quote.addAsCode": "Añadir como código",
|
||||
"messageSection.quote.copy": "Copiar",
|
||||
"messageSection.quote.copied": "¡Copiado!",
|
||||
"messageSection.quote.copyFailed": "No se pudo copiar",
|
||||
|
||||
"messageTimeline.ariaLabel": "Línea de tiempo de mensajes",
|
||||
"messageTimeline.segment.user.label": "Tú",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Ir a sesión",
|
||||
"messageBlock.tool.goToSession.title": "Ir a la sesión",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
|
||||
"messageBlock.tool.deletePart.label": "Eliminar",
|
||||
"messageBlock.tool.deletePart.deleting": "Eliminando...",
|
||||
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
|
||||
"messageBlock.tool.deletePart.failed.title": "Error al eliminar",
|
||||
"messageBlock.tool.deletePart.failed.message": "No se pudo eliminar la salida de herramienta",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Compactación de sesión",
|
||||
"messageBlock.compaction.autoLabel": "Sesión compactada automáticamente",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Generando...",
|
||||
"messageItem.status.sending": "Enviando...",
|
||||
"messageItem.status.failedToSend": "No se pudo enviar el mensaje",
|
||||
"messagePart.actions.delete": "Eliminar",
|
||||
"messagePart.actions.deleting": "Eliminando...",
|
||||
"messagePart.actions.deleteTitle": "Eliminar este elemento",
|
||||
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
|
||||
"messagePart.actions.deleteFailedMessage": "No se pudo eliminar el elemento",
|
||||
"messageItem.attachment.defaultName": "adjunto",
|
||||
"messageItem.attachment.downloadAriaLabel": "Descargar {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Agente: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "ID de sesión copiado",
|
||||
"sessionList.copyId.error": "No se pudo copiar el ID de sesión",
|
||||
"sessionList.delete.error": "No se pudo eliminar la sesión",
|
||||
"sessionList.delete.title": "Eliminar sesión",
|
||||
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",
|
||||
"sessionList.delete.confirmLabel": "Eliminar",
|
||||
"sessionList.delete.cancelLabel": "Cancelar",
|
||||
"sessionList.rename.error": "No se pudo renombrar la sesión",
|
||||
|
||||
"sessionList.filter.placeholder": "Buscar sesiones…",
|
||||
"sessionList.filter.ariaLabel": "Buscar sesiones",
|
||||
"sessionList.selection.selectAllLabel": "Seleccionar todo",
|
||||
"sessionList.selection.selectAllAriaLabel": "Seleccionar todas las sesiones",
|
||||
"sessionList.selection.clearLabel": "Limpiar",
|
||||
"sessionList.selection.clearAriaLabel": "Limpiar selección",
|
||||
"sessionList.selection.checkboxAriaLabel": "Seleccionar sesión",
|
||||
"sessionList.bulkDelete.button": "Eliminar {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Eliminar {count} sesiones seleccionadas",
|
||||
"sessionList.bulkDelete.title": "Eliminar sesiones",
|
||||
"sessionList.bulkDelete.confirmMessage": "¿Eliminar {count} sesiones seleccionadas? Esto no se puede deshacer.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Eliminar",
|
||||
"sessionList.bulkDelete.cancelLabel": "Cancelar",
|
||||
"sessionList.bulkDelete.error": "No se pudieron eliminar {count} sesiones",
|
||||
|
||||
"sessionRenameDialog.title": "Renombrar sesión",
|
||||
"sessionRenameDialog.description.withLabel": "Actualiza el título de \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Establece un nuevo título para esta sesión.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Esperando para ejecutar...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
||||
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
||||
"messageSection.quote.addAsCode": "Ajouter en code",
|
||||
"messageSection.quote.copy": "Copier",
|
||||
"messageSection.quote.copied": "Copié !",
|
||||
"messageSection.quote.copyFailed": "Impossible de copier",
|
||||
|
||||
"messageTimeline.ariaLabel": "Chronologie des messages",
|
||||
"messageTimeline.segment.user.label": "Vous",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Aller à la session",
|
||||
"messageBlock.tool.goToSession.title": "Aller à la session",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
|
||||
"messageBlock.tool.deletePart.label": "Supprimer",
|
||||
"messageBlock.tool.deletePart.deleting": "Suppression...",
|
||||
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
|
||||
"messageBlock.tool.deletePart.failed.title": "Échec de suppression",
|
||||
"messageBlock.tool.deletePart.failed.message": "Impossible de supprimer la sortie d'outil",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Compaction de la session",
|
||||
"messageBlock.compaction.autoLabel": "Session compactée automatiquement",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Génération...",
|
||||
"messageItem.status.sending": "Envoi...",
|
||||
"messageItem.status.failedToSend": "Échec de l'envoi du message",
|
||||
"messagePart.actions.delete": "Supprimer",
|
||||
"messagePart.actions.deleting": "Suppression...",
|
||||
"messagePart.actions.deleteTitle": "Supprimer cet élément",
|
||||
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
|
||||
"messagePart.actions.deleteFailedMessage": "Impossible de supprimer l'élément",
|
||||
"messageItem.attachment.defaultName": "piece-jointe",
|
||||
"messageItem.attachment.downloadAriaLabel": "Télécharger {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Agent : {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "ID de session copié",
|
||||
"sessionList.copyId.error": "Impossible de copier l'ID de session",
|
||||
"sessionList.delete.error": "Impossible de supprimer la session",
|
||||
"sessionList.delete.title": "Supprimer la session",
|
||||
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",
|
||||
"sessionList.delete.confirmLabel": "Supprimer",
|
||||
"sessionList.delete.cancelLabel": "Annuler",
|
||||
"sessionList.rename.error": "Impossible de renommer la session",
|
||||
|
||||
"sessionList.filter.placeholder": "Rechercher des sessions…",
|
||||
"sessionList.filter.ariaLabel": "Rechercher des sessions",
|
||||
"sessionList.selection.selectAllLabel": "Tout sélectionner",
|
||||
"sessionList.selection.selectAllAriaLabel": "Sélectionner toutes les sessions",
|
||||
"sessionList.selection.clearLabel": "Effacer",
|
||||
"sessionList.selection.clearAriaLabel": "Effacer la sélection",
|
||||
"sessionList.selection.checkboxAriaLabel": "Sélectionner la session",
|
||||
"sessionList.bulkDelete.button": "Supprimer {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Supprimer {count} sessions sélectionnées",
|
||||
"sessionList.bulkDelete.title": "Supprimer des sessions",
|
||||
"sessionList.bulkDelete.confirmMessage": "Supprimer {count} sessions sélectionnées ? Cette action est irréversible.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Supprimer",
|
||||
"sessionList.bulkDelete.cancelLabel": "Annuler",
|
||||
"sessionList.bulkDelete.error": "Impossible de supprimer {count} sessions",
|
||||
|
||||
"sessionRenameDialog.title": "Renommer la session",
|
||||
"sessionRenameDialog.description.withLabel": "Mettre à jour le titre de \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Définir un nouveau titre pour cette session.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "En attente d'exécution...",
|
||||
"toolCall.error.label": "Erreur :",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
||||
"messageSection.quote.addAsQuote": "引用として追加",
|
||||
"messageSection.quote.addAsCode": "コードとして追加",
|
||||
"messageSection.quote.copy": "コピー",
|
||||
"messageSection.quote.copied": "コピーしました",
|
||||
"messageSection.quote.copyFailed": "コピーできませんでした",
|
||||
|
||||
"messageTimeline.ariaLabel": "メッセージタイムライン",
|
||||
"messageTimeline.segment.user.label": "あなた",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "セッションへ移動",
|
||||
"messageBlock.tool.goToSession.title": "セッションへ移動",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
|
||||
"messageBlock.tool.deletePart.label": "削除",
|
||||
"messageBlock.tool.deletePart.deleting": "削除中...",
|
||||
"messageBlock.tool.deletePart.title": "このツール出力を削除",
|
||||
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
|
||||
"messageBlock.tool.deletePart.failed.message": "ツール出力の削除に失敗しました",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "セッションのコンパクト化",
|
||||
"messageBlock.compaction.autoLabel": "セッションを自動でコンパクト化しました",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "生成中...",
|
||||
"messageItem.status.sending": "送信中...",
|
||||
"messageItem.status.failedToSend": "メッセージの送信に失敗しました",
|
||||
"messagePart.actions.delete": "削除",
|
||||
"messagePart.actions.deleting": "削除中...",
|
||||
"messagePart.actions.deleteTitle": "この項目を削除",
|
||||
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
|
||||
"messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました",
|
||||
"messageItem.attachment.defaultName": "添付ファイル",
|
||||
"messageItem.attachment.downloadAriaLabel": "{name} をダウンロード",
|
||||
"messageItem.agentMeta.agentLabel": "エージェント: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "セッション ID をコピーしました",
|
||||
"sessionList.copyId.error": "セッション ID をコピーできません",
|
||||
"sessionList.delete.error": "セッションを削除できません",
|
||||
"sessionList.delete.title": "セッションを削除",
|
||||
"sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。",
|
||||
"sessionList.delete.confirmLabel": "削除",
|
||||
"sessionList.delete.cancelLabel": "キャンセル",
|
||||
"sessionList.rename.error": "セッション名を変更できません",
|
||||
|
||||
"sessionList.filter.placeholder": "セッションを検索…",
|
||||
"sessionList.filter.ariaLabel": "セッションを検索",
|
||||
"sessionList.selection.selectAllLabel": "すべて選択",
|
||||
"sessionList.selection.selectAllAriaLabel": "すべてのセッションを選択",
|
||||
"sessionList.selection.clearLabel": "クリア",
|
||||
"sessionList.selection.clearAriaLabel": "選択をクリア",
|
||||
"sessionList.selection.checkboxAriaLabel": "セッションを選択",
|
||||
"sessionList.bulkDelete.button": "{count} 件を削除",
|
||||
"sessionList.bulkDelete.ariaLabel": "選択した {count} 件のセッションを削除",
|
||||
"sessionList.bulkDelete.title": "セッションを削除",
|
||||
"sessionList.bulkDelete.confirmMessage": "選択した {count} 件のセッションを削除しますか?この操作は元に戻せません。",
|
||||
"sessionList.bulkDelete.confirmLabel": "削除",
|
||||
"sessionList.bulkDelete.cancelLabel": "キャンセル",
|
||||
"sessionList.bulkDelete.error": "{count} 件のセッションを削除できません",
|
||||
|
||||
"sessionRenameDialog.title": "セッション名を変更",
|
||||
"sessionRenameDialog.description.withLabel": "\"{label}\" のタイトルを更新します。",
|
||||
"sessionRenameDialog.description.default": "このセッションの新しいタイトルを設定します。",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "実行待ち...",
|
||||
"toolCall.error.label": "エラー:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
||||
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
||||
"messageSection.quote.addAsCode": "Добавить как код",
|
||||
"messageSection.quote.copy": "Копировать",
|
||||
"messageSection.quote.copied": "Скопировано!",
|
||||
"messageSection.quote.copyFailed": "Не удалось скопировать",
|
||||
|
||||
"messageTimeline.ariaLabel": "Таймлайн сообщений",
|
||||
"messageTimeline.segment.user.label": "Вы",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Перейти к сессии",
|
||||
"messageBlock.tool.goToSession.title": "Перейти к сессии",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
|
||||
"messageBlock.tool.deletePart.label": "Удалить",
|
||||
"messageBlock.tool.deletePart.deleting": "Удаление...",
|
||||
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
|
||||
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
|
||||
"messageBlock.tool.deletePart.failed.message": "Не удалось удалить вывод инструмента",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Компактация сессии",
|
||||
"messageBlock.compaction.autoLabel": "Сессия автоматически компактирована",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Генерация…",
|
||||
"messageItem.status.sending": "Отправка…",
|
||||
"messageItem.status.failedToSend": "Не удалось отправить сообщение",
|
||||
"messagePart.actions.delete": "Удалить",
|
||||
"messagePart.actions.deleting": "Удаление...",
|
||||
"messagePart.actions.deleteTitle": "Удалить этот элемент",
|
||||
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
|
||||
"messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент",
|
||||
"messageItem.attachment.defaultName": "вложение",
|
||||
"messageItem.attachment.downloadAriaLabel": "Скачать {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Агент: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "ID сессии скопирован",
|
||||
"sessionList.copyId.error": "Не удалось скопировать ID сессии",
|
||||
"sessionList.delete.error": "Не удалось удалить сессию",
|
||||
"sessionList.delete.title": "Удалить сессию",
|
||||
"sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.",
|
||||
"sessionList.delete.confirmLabel": "Удалить",
|
||||
"sessionList.delete.cancelLabel": "Отмена",
|
||||
"sessionList.rename.error": "Не удалось переименовать сессию",
|
||||
|
||||
"sessionList.filter.placeholder": "Поиск сессий…",
|
||||
"sessionList.filter.ariaLabel": "Поиск сессий",
|
||||
"sessionList.selection.selectAllLabel": "Выбрать все",
|
||||
"sessionList.selection.selectAllAriaLabel": "Выбрать все сессии",
|
||||
"sessionList.selection.clearLabel": "Очистить",
|
||||
"sessionList.selection.clearAriaLabel": "Очистить выбор",
|
||||
"sessionList.selection.checkboxAriaLabel": "Выбрать сессию",
|
||||
"sessionList.bulkDelete.button": "Удалить {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Удалить {count} выбранных сессий",
|
||||
"sessionList.bulkDelete.title": "Удалить сессии",
|
||||
"sessionList.bulkDelete.confirmMessage": "Удалить {count} выбранных сессий? Это действие нельзя отменить.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Удалить",
|
||||
"sessionList.bulkDelete.cancelLabel": "Отмена",
|
||||
"sessionList.bulkDelete.error": "Не удалось удалить {count} сессий",
|
||||
|
||||
"sessionRenameDialog.title": "Переименовать сессию",
|
||||
"sessionRenameDialog.description.withLabel": "Обновите название для \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Установите новое название для этой сессии.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Ожидание запуска…",
|
||||
"toolCall.error.label": "Ошибка:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
||||
"messageSection.quote.addAsQuote": "作为引用添加",
|
||||
"messageSection.quote.addAsCode": "作为代码添加",
|
||||
"messageSection.quote.copy": "复制",
|
||||
"messageSection.quote.copied": "已复制!",
|
||||
"messageSection.quote.copyFailed": "无法复制",
|
||||
|
||||
"messageTimeline.ariaLabel": "消息时间线",
|
||||
"messageTimeline.segment.user.label": "你",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "前往会话",
|
||||
"messageBlock.tool.goToSession.title": "前往会话",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用",
|
||||
"messageBlock.tool.deletePart.label": "删除",
|
||||
"messageBlock.tool.deletePart.deleting": "正在删除...",
|
||||
"messageBlock.tool.deletePart.title": "删除此工具输出",
|
||||
"messageBlock.tool.deletePart.failed.title": "删除失败",
|
||||
"messageBlock.tool.deletePart.failed.message": "删除工具输出失败",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "会话压缩",
|
||||
"messageBlock.compaction.autoLabel": "会话已自动压缩",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "正在生成...",
|
||||
"messageItem.status.sending": "正在发送...",
|
||||
"messageItem.status.failedToSend": "消息发送失败",
|
||||
"messagePart.actions.delete": "删除",
|
||||
"messagePart.actions.deleting": "正在删除...",
|
||||
"messagePart.actions.deleteTitle": "删除此项",
|
||||
"messagePart.actions.deleteFailedTitle": "删除失败",
|
||||
"messagePart.actions.deleteFailedMessage": "删除失败",
|
||||
"messageItem.attachment.defaultName": "附件",
|
||||
"messageItem.attachment.downloadAriaLabel": "下载 {name}",
|
||||
"messageItem.agentMeta.agentLabel": "智能体:{agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "已复制会话 ID",
|
||||
"sessionList.copyId.error": "无法复制会话 ID",
|
||||
"sessionList.delete.error": "无法删除会话",
|
||||
"sessionList.delete.title": "删除会话",
|
||||
"sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。",
|
||||
"sessionList.delete.confirmLabel": "删除",
|
||||
"sessionList.delete.cancelLabel": "取消",
|
||||
"sessionList.rename.error": "无法重命名会话",
|
||||
|
||||
"sessionList.filter.placeholder": "搜索会话…",
|
||||
"sessionList.filter.ariaLabel": "搜索会话",
|
||||
"sessionList.selection.selectAllLabel": "全选",
|
||||
"sessionList.selection.selectAllAriaLabel": "选择所有会话",
|
||||
"sessionList.selection.clearLabel": "清除",
|
||||
"sessionList.selection.clearAriaLabel": "清除选择",
|
||||
"sessionList.selection.checkboxAriaLabel": "选择会话",
|
||||
"sessionList.bulkDelete.button": "删除 {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "删除已选择的 {count} 个会话",
|
||||
"sessionList.bulkDelete.title": "删除会话",
|
||||
"sessionList.bulkDelete.confirmMessage": "删除已选择的 {count} 个会话?此操作无法撤销。",
|
||||
"sessionList.bulkDelete.confirmLabel": "删除",
|
||||
"sessionList.bulkDelete.cancelLabel": "取消",
|
||||
"sessionList.bulkDelete.error": "无法删除 {count} 个会话",
|
||||
|
||||
"sessionRenameDialog.title": "重命名会话",
|
||||
"sessionRenameDialog.description.withLabel": "更新“{label}”的标题。",
|
||||
"sessionRenameDialog.description.default": "为此会话设置新标题。",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "等待运行...",
|
||||
"toolCall.error.label": "错误:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
||||
|
||||
@@ -91,7 +91,7 @@ async function getOrCreateHighlighter() {
|
||||
|
||||
// Create highlighter with no preloaded languages
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-light", "github-dark"],
|
||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||
langs: [],
|
||||
})
|
||||
|
||||
@@ -242,9 +242,9 @@ async function runLanguageLoadQueue() {
|
||||
}
|
||||
|
||||
function setupRenderer(isDark: boolean) {
|
||||
if (!highlighter || rendererSetup) return
|
||||
|
||||
currentTheme = isDark ? "dark" : "light"
|
||||
if (!highlighter) return
|
||||
if (rendererSetup) return
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
@@ -296,10 +296,10 @@ function setupRenderer(isDark: boolean) {
|
||||
// Use highlighting if language is loaded, otherwise fall back to plain code
|
||||
if (loadedLanguages.has(langKey)) {
|
||||
try {
|
||||
const html = highlighter!.codeToHtml(decodedCode, {
|
||||
lang: langKey,
|
||||
theme: currentTheme === "dark" ? "github-dark" : "github-light",
|
||||
})
|
||||
const html = highlighter!.codeToHtml(decodedCode, {
|
||||
lang: langKey,
|
||||
theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast",
|
||||
})
|
||||
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
|
||||
} catch {
|
||||
// Fall through to plain code if highlighting fails
|
||||
@@ -329,6 +329,10 @@ export async function initMarkdown(isDark: boolean) {
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
export function setMarkdownTheme(isDark: boolean) {
|
||||
currentTheme = isDark ? "dark" : "light"
|
||||
}
|
||||
|
||||
export function isMarkdownReady(): boolean {
|
||||
return isInitialized && highlighter !== null
|
||||
}
|
||||
|
||||
@@ -3,22 +3,24 @@ import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/s
|
||||
import CssBaseline from "@suid/material/CssBaseline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
export type ThemeMode = "system" | "light" | "dark"
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: () => boolean
|
||||
toggleTheme: () => void
|
||||
setTheme: (dark: boolean) => void
|
||||
themeMode: () => ThemeMode
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
cycleThemeMode: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
function applyTheme(dark: boolean) {
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
if (typeof document === "undefined") return
|
||||
if (dark) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
if (mode === "system") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
document.documentElement.setAttribute("data-theme", mode)
|
||||
}
|
||||
|
||||
interface ResolvedPaletteColors {
|
||||
@@ -78,16 +80,31 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
|
||||
const { themePreference, setThemePreference } = useConfig()
|
||||
const [isDark, setIsDarkSignal] = createSignal(true)
|
||||
const [themeRevision, setThemeRevision] = createSignal(0)
|
||||
|
||||
const themeMode = () => themePreference() as ThemeMode
|
||||
|
||||
const resolveDarkTheme = () => {
|
||||
themePreference()
|
||||
return true
|
||||
const mode = themeMode()
|
||||
if (mode === "dark") return true
|
||||
if (mode === "light") return false
|
||||
return mediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
const applyResolvedTheme = () => {
|
||||
const mode = themeMode()
|
||||
const dark = resolveDarkTheme()
|
||||
if (mode === "system") {
|
||||
applyThemeMode("system")
|
||||
} else {
|
||||
applyThemeMode(mode)
|
||||
}
|
||||
setIsDarkSignal(dark)
|
||||
applyTheme(dark)
|
||||
if (typeof window !== "undefined") {
|
||||
requestAnimationFrame(() => setThemeRevision((v) => v + 1))
|
||||
} else {
|
||||
setThemeRevision((v) => v + 1)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -107,15 +124,18 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
}
|
||||
})
|
||||
|
||||
const setTheme = (_dark: boolean) => {
|
||||
setThemePreference("dark")
|
||||
const setThemeMode = (mode: ThemeMode) => {
|
||||
setThemePreference(mode)
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(true)
|
||||
const cycleThemeMode = () => {
|
||||
const current = themeMode()
|
||||
const next: ThemeMode = current === "system" ? "light" : current === "light" ? "dark" : "system"
|
||||
setThemeMode(next)
|
||||
}
|
||||
|
||||
const muiTheme = createMemo(() => {
|
||||
themeRevision()
|
||||
const paletteColors = resolvePaletteColors(isDark())
|
||||
return createTheme({
|
||||
palette: {
|
||||
@@ -144,21 +164,32 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: "inherit",
|
||||
"&.Mui-disabled": {
|
||||
color: "var(--text-muted)",
|
||||
opacity: 0.55,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
boxShadow: "none",
|
||||
borderBottom: `1px solid ${paletteColors.divider}`,
|
||||
borderBottom: "1px solid var(--border-base)",
|
||||
zIndex: 10,
|
||||
},
|
||||
},
|
||||
@@ -175,7 +206,7 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>
|
||||
<ThemeContext.Provider value={{ isDark, themeMode, setThemeMode, cycleThemeMode }}>
|
||||
<MuiThemeProvider theme={muiTheme()}>
|
||||
<CssBaseline />
|
||||
{props.children}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigProvider } from "./stores/preferences"
|
||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { I18nProvider } from "./lib/i18n"
|
||||
import { storage } from "./lib/storage"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
|
||||
@@ -14,22 +15,48 @@ if (!root) {
|
||||
throw new Error("Root element not found")
|
||||
}
|
||||
|
||||
const mount = root
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.runtimeHost = runtimeEnv.host
|
||||
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
<InstanceConfigProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</InstanceConfigProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
async function bootstrap() {
|
||||
if (typeof document !== "undefined") {
|
||||
// renderer/index.html currently seeds a dark theme to avoid a white flash.
|
||||
// Reset to CSS defaults immediately so the first render matches system
|
||||
// (and then refine once persisted config loads).
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
const theme = config?.theme ?? "system"
|
||||
|
||||
if (theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
}
|
||||
} catch {
|
||||
// If config fails to load, fall back to CSS defaults.
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
<InstanceConfigProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</InstanceConfigProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
mount,
|
||||
)
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--surface-base, #0f141f);
|
||||
color: var(--text-primary, #cfd4dc);
|
||||
background-color: var(--surface-base, #ffffff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
font-family: var(--font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -34,7 +34,7 @@ button {
|
||||
.loading-logo {
|
||||
width: 180px;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
|
||||
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
.loading-heading {
|
||||
@@ -47,13 +47,13 @@ button {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #f4f6fb);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.loading-status {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted, #aeb3c4);
|
||||
color: var(--text-muted, #666666);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
@@ -62,9 +62,9 @@ button {
|
||||
max-width: 420px;
|
||||
padding: 22px;
|
||||
border-radius: 18px;
|
||||
background: rgba(13, 16, 24, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
|
||||
background: var(--surface-secondary, #f5f5f5);
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
box-shadow: var(--panel-shadow-strong, 0 25px 60px rgba(0, 0, 0, 0.16));
|
||||
}
|
||||
|
||||
.loading-row {
|
||||
@@ -79,28 +79,74 @@ button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: #6ce3ff;
|
||||
border: 2px solid color-mix(in srgb, var(--text-primary, #1a1a1a) 14%, transparent);
|
||||
border-top-color: var(--accent-primary, #0066ff);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.phrase-controls {
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #8f96a9);
|
||||
color: var(--text-muted, #666666);
|
||||
}
|
||||
|
||||
.phrase-controls button {
|
||||
color: #8fb5ff;
|
||||
color: var(--accent-primary, #0066ff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading-error {
|
||||
margin-top: 12px;
|
||||
color: #ff9ea9;
|
||||
color: var(--status-error, #dc2626);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) body {
|
||||
background-color: var(--surface-base, #0f141f);
|
||||
color: var(--text-primary, #cfd4dc);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-logo {
|
||||
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-title {
|
||||
color: var(--text-primary, #f4f6fb);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-status {
|
||||
color: var(--text-muted, #aeb3c4);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-card {
|
||||
background: rgba(13, 16, 24, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .spinner {
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: var(--accent-primary, #6ce3ff);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .phrase-controls {
|
||||
color: var(--text-muted, #8f96a9);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-error {
|
||||
color: var(--status-error, #ff9ea9);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -194,7 +194,7 @@ const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
const preferences = createMemo<Preferences>(() => internalConfig().preferences)
|
||||
const recentFolders = createMemo<RecentFolder[]>(() => internalConfig().recentFolders ?? [])
|
||||
const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => internalConfig().opencodeBinaries ?? [])
|
||||
const themePreference = createMemo<ThemePreference>(() => internalConfig().theme ?? "dark")
|
||||
const themePreference = createMemo<ThemePreference>(() => internalConfig().theme ?? "system")
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
function normalizeConfig(config?: ConfigData | null): ConfigData {
|
||||
@@ -202,7 +202,7 @@ function normalizeConfig(config?: ConfigData | null): ConfigData {
|
||||
preferences: normalizePreferences(config?.preferences),
|
||||
recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })),
|
||||
opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })),
|
||||
theme: config?.theme ?? "dark",
|
||||
theme: config?.theme ?? "system",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { providers, sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { removeMessagePartV2 } from "./message-v2/bridge"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
||||
@@ -395,8 +396,30 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteMessagePart(instanceId: string, sessionId: string, messageId: string, partId: string): Promise<void> {
|
||||
if (!instanceId || !sessionId || !messageId || !partId) return
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
await requestData(
|
||||
instance.client.part.delete({
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
partID: partId,
|
||||
}),
|
||||
"part.delete",
|
||||
)
|
||||
|
||||
// Optimistic removal; SSE will also broadcast a part-removed event.
|
||||
removeMessagePartV2(instanceId, messageId, partId)
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
deleteMessagePart,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
.permission-center-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--text-inverted) 55%, transparent);
|
||||
background: var(--overlay-scrim);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -52,7 +52,7 @@
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--panel-shadow, 0 12px 32px rgba(0, 0, 0, 0.25));
|
||||
box-shadow: var(--panel-shadow-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -234,4 +234,4 @@
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@
|
||||
|
||||
.message-reasoning {
|
||||
@apply my-2 border rounded;
|
||||
border-color: var(--border-base);
|
||||
--reasoning-border-color: var(--border-strong, var(--border-base));
|
||||
border-color: var(--reasoning-border-color);
|
||||
background-color: var(--surface-secondary);
|
||||
color: inherit;
|
||||
}
|
||||
@@ -286,6 +287,7 @@
|
||||
}
|
||||
|
||||
.message-reasoning-card {
|
||||
--reasoning-border-color: var(--border-strong, var(--border-base));
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
margin-top: 0;
|
||||
@@ -339,7 +341,7 @@
|
||||
justify-content: center;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--reasoning-border-color, var(--border-base));
|
||||
border-radius: 0.375rem;
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
@@ -381,6 +383,7 @@
|
||||
@apply flex flex-col;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--reasoning-border-color, var(--border-base));
|
||||
max-height: 30rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
@@ -397,4 +400,3 @@
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--list-item-highlight-border);
|
||||
background-color: var(--list-item-highlight-bg-solid);
|
||||
box-shadow: var(--panel-shadow, 0 4px 16px rgba(0, 0, 0, 0.2));
|
||||
box-shadow: var(--panel-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
background-color: var(--surface-base);
|
||||
box-shadow: var(--panel-shadow, 0 6px 24px rgba(0, 0, 0, 0.2));
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.message-timeline::-webkit-scrollbar {
|
||||
@@ -103,11 +103,11 @@
|
||||
}
|
||||
|
||||
.message-timeline-segment-active {
|
||||
border-color: transparent;
|
||||
background-color: #0f5b44;
|
||||
color: #fff;
|
||||
border-color: color-mix(in oklab, var(--timeline-segment-active-bg) 92%, var(--timeline-segment-active-text));
|
||||
background-color: var(--timeline-segment-active-bg);
|
||||
color: var(--timeline-segment-active-text);
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
box-shadow: var(--timeline-segment-active-ring);
|
||||
}
|
||||
|
||||
.message-timeline-segment:hover,
|
||||
@@ -121,10 +121,10 @@
|
||||
.message-timeline-segment-active,
|
||||
.message-timeline-segment-active:hover,
|
||||
.message-timeline-segment-active:focus-visible {
|
||||
background-color: #0f5b44;
|
||||
color: #fff;
|
||||
background-color: var(--timeline-segment-active-bg);
|
||||
color: var(--timeline-segment-active-text);
|
||||
transform: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
box-shadow: var(--timeline-segment-active-ring);
|
||||
}
|
||||
|
||||
.message-timeline-segment:focus-visible {
|
||||
@@ -167,7 +167,7 @@
|
||||
border-color: var(--session-status-permission-fg) !important;
|
||||
color: var(--session-status-permission-fg) !important;
|
||||
transform: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
box-shadow: var(--timeline-segment-active-ring);
|
||||
}
|
||||
|
||||
.message-timeline-compaction-auto {
|
||||
@@ -181,11 +181,11 @@
|
||||
}
|
||||
|
||||
.message-timeline-segment-active {
|
||||
background-color: #0f5b44 !important;
|
||||
border-color: transparent !important;
|
||||
color: #fff !important;
|
||||
background-color: var(--timeline-segment-active-bg) !important;
|
||||
border-color: color-mix(in oklab, var(--timeline-segment-active-bg) 92%, var(--timeline-segment-active-text)) !important;
|
||||
color: var(--timeline-segment-active-text) !important;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
box-shadow: var(--timeline-segment-active-ring);
|
||||
}
|
||||
|
||||
.message-timeline-label {
|
||||
@@ -221,7 +221,7 @@
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
box-shadow: var(--panel-shadow, 0 12px 32px rgba(0, 0, 0, 0.25));
|
||||
box-shadow: var(--panel-shadow-strong);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
color: var(--text-primary);
|
||||
caret-color: var(--text-primary);
|
||||
border-color: var(--border-base);
|
||||
line-height: var(--line-height-normal);
|
||||
border-radius: 0;
|
||||
@@ -97,7 +98,7 @@
|
||||
.prompt-history-button {
|
||||
@apply w-7 h-7 flex items-center justify-center rounded-md;
|
||||
color: var(--text-muted);
|
||||
background-color: rgba(15, 23, 42, 0.04);
|
||||
background-color: var(--control-ghost-bg);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
@@ -143,7 +144,7 @@
|
||||
|
||||
.prompt-input.shell-mode {
|
||||
border-color: var(--status-success);
|
||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||
box-shadow: inset 0 0 0 1px var(--status-success-ring);
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
@@ -152,7 +153,7 @@
|
||||
|
||||
.prompt-input.shell-mode:focus {
|
||||
border-color: var(--status-success);
|
||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||
box-shadow: inset 0 0 0 1px var(--status-success-ring);
|
||||
}
|
||||
|
||||
.prompt-input:disabled {
|
||||
@@ -165,17 +166,17 @@
|
||||
|
||||
.stop-button {
|
||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||
background-color: rgba(239, 68, 68, 0.85);
|
||||
color: var(--text-inverted);
|
||||
background-color: var(--button-danger-bg, rgba(239, 68, 68, 0.85));
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.stop-button:hover:not(:disabled) {
|
||||
background-color: rgba(239, 68, 68, 0.9);
|
||||
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
||||
@apply opacity-95 scale-105;
|
||||
}
|
||||
|
||||
.stop-button:active:not(:disabled) {
|
||||
background-color: rgba(239, 68, 68, 1);
|
||||
background-color: var(--button-danger-active-bg, rgba(239, 68, 68, 1));
|
||||
@apply scale-95;
|
||||
}
|
||||
|
||||
@@ -260,7 +261,7 @@
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.25);
|
||||
box-shadow: var(--popover-shadow);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.tool-call-header-label {
|
||||
@apply flex items-center justify-between gap-2 font-semibold text-sm;
|
||||
color: var(--message-tool-border);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
.tool-call-header-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--message-tool-border);
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
color: var(--text-secondary);
|
||||
padding: 0.15rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
@@ -58,7 +58,7 @@
|
||||
font-family: var(--font-family-mono);
|
||||
color: inherit;
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
@@ -66,7 +66,9 @@
|
||||
|
||||
.tool-call {
|
||||
@apply border overflow-hidden;
|
||||
border-color: var(--border-base);
|
||||
/* Tokenized so dark mode doesn't get overly bright borders. */
|
||||
--tool-call-border-color: var(--border-strong, var(--border-base));
|
||||
border-color: var(--tool-call-border-color);
|
||||
color: inherit;
|
||||
--tool-call-line-unit: 1.4em;
|
||||
--tool-call-lines-compact: 15;
|
||||
@@ -82,39 +84,64 @@
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tool-call-header::before {
|
||||
content: "▶";
|
||||
font-size: 11px;
|
||||
margin-right: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-header[aria-expanded="true"]::before {
|
||||
content: "▼";
|
||||
}
|
||||
|
||||
.tool-call-header::after {
|
||||
content: attr(data-status-icon);
|
||||
font-size: 0.95rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-header[data-status-icon=""]::after {
|
||||
margin-left: 0;
|
||||
@apply flex items-stretch w-full;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-header-toggle {
|
||||
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
border-radius: 0;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-call-header-toggle::before {
|
||||
content: "▶";
|
||||
font-size: 11px;
|
||||
margin-right: 0.35rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-call-header-toggle[aria-expanded="true"]::before {
|
||||
content: "▼";
|
||||
}
|
||||
|
||||
.tool-call-header-toggle:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-header-copy {
|
||||
@apply inline-flex items-center justify-center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-call-header-copy:hover {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-header-status {
|
||||
@apply inline-flex items-center justify-center;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
@apply flex-1 text-left inline-flex items-center gap-2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-summary::before {
|
||||
@@ -130,6 +157,8 @@
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
/* ToolState uses status="completed"; keep "success" as a legacy alias. */
|
||||
.tool-call-status-completed,
|
||||
.tool-call-status-success {
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
@@ -157,12 +186,12 @@
|
||||
.tool-call-preview {
|
||||
@apply p-2 flex flex-col gap-1.5;
|
||||
background-color: var(--surface-code);
|
||||
border-top: 1px solid var(--border-base);
|
||||
border-top: 1px solid var(--tool-call-border-color);
|
||||
}
|
||||
|
||||
.tool-call-preview-label {
|
||||
@apply text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -170,7 +199,7 @@
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
@@ -186,7 +215,8 @@
|
||||
|
||||
.tool-call-markdown {
|
||||
background-color: var(--surface-code);
|
||||
border: none;
|
||||
/* Keep a visible frame around the scroll viewport (not the content). */
|
||||
border: 1px solid var(--tool-call-border-color);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
@@ -199,6 +229,16 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Inner code blocks should not own the frame border; the scroll container does. */
|
||||
.tool-call-markdown .markdown-code-block {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Avoid double borders when ANSI output uses .tool-call-content inside tool-call-markdown. */
|
||||
.tool-call-markdown .tool-call-content {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tool-call-markdown-large {
|
||||
max-height: var(--tool-call-max-height-large, calc(48 * 1.4em));
|
||||
}
|
||||
@@ -216,7 +256,7 @@
|
||||
.tool-call-diff-toolbar {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
border-bottom: 1px solid var(--tool-call-border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
@@ -231,7 +271,7 @@
|
||||
|
||||
.tool-call-diff-toolbar-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
@@ -242,9 +282,9 @@
|
||||
|
||||
.tool-call-diff-mode-button {
|
||||
@apply border text-xs font-semibold px-3 py-1 rounded transition-all duration-150;
|
||||
border-color: var(--border-base);
|
||||
border-color: var(--tool-call-border-color, var(--border-base));
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button:hover {
|
||||
@@ -289,6 +329,10 @@
|
||||
background-color: var(--message-tool-bg);
|
||||
}
|
||||
|
||||
.tool-call-permission.tool-call-permission-answered {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-permission-header {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
@@ -303,8 +347,9 @@
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
background-color: var(--surface-code);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-permission-title code {
|
||||
@@ -312,7 +357,7 @@
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-code);
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
word-break: break-word;
|
||||
@@ -388,19 +433,13 @@
|
||||
.tool-call-diff-viewer .diff-line-old-num,
|
||||
.tool-call-diff-viewer .diff-line-new-num,
|
||||
.tool-call-diff-viewer .diff-line-num {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tool-call-markdown .markdown-code-block {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
.tool-call-markdown .markdown-code-block {
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -408,7 +447,25 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: auto;
|
||||
box-shadow: 0 1px 0 var(--border-base);
|
||||
background-color: var(--code-block-header-bg, var(--surface-secondary));
|
||||
border-bottom: 1px solid var(--tool-call-border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Tool output header (language + copy) needs stronger contrast in light mode. */
|
||||
.tool-call-markdown .code-block-language {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-markdown .code-block-copy {
|
||||
border-color: var(--tool-call-border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Plain (non-highlighted) tool output must remain readable in light mode. */
|
||||
.tool-call-markdown .markdown-code-block pre:not(.shiki),
|
||||
.tool-call-markdown .markdown-code-block pre:not(.shiki) code {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-markdown .markdown-code-block pre {
|
||||
@@ -418,6 +475,14 @@
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* Shiki injects inline background colors; force token surfaces. */
|
||||
.tool-call-markdown pre.shiki,
|
||||
.tool-call-markdown pre.shiki code,
|
||||
.tool-call-markdown .shiki {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.tool-call-markdown::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
@@ -450,7 +515,7 @@
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics {
|
||||
@@ -458,12 +523,12 @@
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-top: 1px solid var(--border-base);
|
||||
border-top: 1px solid var(--tool-call-border-color);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-wrapper {
|
||||
border-top: 1px solid var(--border-base);
|
||||
border-top: 1px solid var(--tool-call-border-color);
|
||||
background-color: var(--surface-base);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
@@ -472,7 +537,7 @@
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--message-tool-border);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
@@ -496,7 +561,7 @@
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--message-tool-border);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
@@ -515,7 +580,7 @@
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -535,7 +600,7 @@
|
||||
|
||||
.tool-call-diagnostics-caret {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics {
|
||||
@@ -615,7 +680,8 @@
|
||||
.tool-call-section pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-base);
|
||||
background-color: var(--surface-code);
|
||||
border: 1px solid var(--tool-call-border-color);
|
||||
border-radius: 0px;
|
||||
overflow-x: auto;
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
@@ -649,7 +715,7 @@
|
||||
|
||||
.tool-call-pending-message {
|
||||
@apply flex items-center gap-2 p-3 text-xs italic;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-call-emoji {
|
||||
@@ -658,8 +724,8 @@
|
||||
|
||||
.tool-call-action-button {
|
||||
@apply border text-xs font-semibold px-3 py-1 rounded transition-colors h-8 flex items-center;
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--tool-call-border-color, var(--border-base));
|
||||
color: var(--text-secondary);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -679,8 +745,8 @@
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: var(--surface-code);
|
||||
border: 1px solid var(--tool-call-border-color);
|
||||
border-radius: 0;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
@@ -688,6 +754,7 @@
|
||||
line-height: var(--line-height-tight);
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-content code {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
.tool-call-task-section {
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
@@ -19,7 +19,7 @@
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
border-bottom: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
@@ -76,16 +76,52 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Nested tool calls (child session tool timeline) */
|
||||
.tool-call-task-summary .tool-call {
|
||||
/* Tool calls inside the main message stream are borderless.
|
||||
Use an overlay stripe so hover backgrounds don't hide it. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-call-task-summary .tool-call::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: var(--task-tool-call-stripe, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tool-call-task-summary .tool-call.tool-call-status-completed,
|
||||
.tool-call-task-summary .tool-call.tool-call-status-success {
|
||||
--task-tool-call-stripe: var(--status-success);
|
||||
}
|
||||
|
||||
.tool-call-task-summary .tool-call.tool-call-status-running {
|
||||
--task-tool-call-stripe: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-task-summary .tool-call.tool-call-status-pending {
|
||||
--task-tool-call-stripe: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-task-summary .tool-call.tool-call-status-error {
|
||||
--task-tool-call-stripe: var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.5rem 0.35rem 0.75rem;
|
||||
border-left: 2px solid var(--border-base);
|
||||
border-left: 2px solid var(--tool-call-border-color, var(--border-base));
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
line-height: 1.35;
|
||||
background-color: var(--surface-code);
|
||||
background-color: var(--surface-secondary);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
.tool-call-todo-item {
|
||||
@apply flex items-start gap-3;
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
border-radius: 0;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--surface-secondary);
|
||||
@@ -40,7 +40,7 @@
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid var(--border-base);
|
||||
border: 2px solid var(--tool-call-border-color, var(--border-base));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -58,7 +58,7 @@
|
||||
.tool-call-todo-checkbox[data-status="completed"] {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
color: var(--text-on-accent, #ffffff);
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="completed"]::after {
|
||||
@@ -155,5 +155,5 @@
|
||||
|
||||
.tool-call-todo-item-active .tool-call-todo-tag {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
color: var(--text-on-accent, #ffffff);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,55 @@
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
/* Instance tabs: session-status dots should be lighter/softer than list/status pills. */
|
||||
.tab-base .status-indicator.session-status.session-working {
|
||||
--session-status-dot: color-mix(in oklab, var(--session-status-working-fg) 55%, var(--surface-base));
|
||||
}
|
||||
|
||||
.tab-base .status-indicator.session-status.session-compacting {
|
||||
--session-status-dot: color-mix(in oklab, var(--session-status-compacting-fg) 55%, var(--surface-base));
|
||||
}
|
||||
|
||||
.tab-base .status-indicator.session-status.session-idle {
|
||||
--session-status-dot: color-mix(in oklab, var(--session-status-idle-fg) 55%, var(--surface-base));
|
||||
}
|
||||
|
||||
.tab-base .status-indicator.session-status.session-permission {
|
||||
--session-status-dot: color-mix(in oklab, var(--session-status-permission-fg) 55%, var(--surface-base));
|
||||
}
|
||||
|
||||
/* Dark mode: keep dots vivid (avoid muddy mixes). */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) .tab-base .status-indicator.session-status.session-working {
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
:root:not([data-theme]) .tab-base .status-indicator.session-status.session-compacting {
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
}
|
||||
:root:not([data-theme]) .tab-base .status-indicator.session-status.session-idle {
|
||||
--session-status-dot: var(--session-status-idle-fg);
|
||||
}
|
||||
:root:not([data-theme]) .tab-base .status-indicator.session-status.session-permission {
|
||||
--session-status-dot: var(--session-status-permission-fg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tab-base .status-indicator.session-status.session-working {
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tab-base .status-indicator.session-status.session-compacting {
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tab-base .status-indicator.session-status.session-idle {
|
||||
--session-status-dot: var(--session-status-idle-fg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tab-base .status-indicator.session-status.session-permission {
|
||||
--session-status-dot: var(--session-status-permission-fg);
|
||||
}
|
||||
|
||||
.new-tab-button {
|
||||
@apply inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors;
|
||||
background-color: var(--new-tab-bg);
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
/* Surface tokens */
|
||||
--surface-base: #ffffff;
|
||||
--surface-secondary: #f5f5f5;
|
||||
--surface-muted: #f8f9fa;
|
||||
--surface-code: #f8f8f8;
|
||||
--surface-muted: #f8fafc;
|
||||
--surface-code: #f1f5f9;
|
||||
--surface-hover: #e0e0e0;
|
||||
|
||||
/* Border tokens */
|
||||
--border-base: #e0e0e0;
|
||||
--border-secondary: #e0e0e0;
|
||||
--border-muted: #e0e0e0;
|
||||
--border-strong: color-mix(in oklab, var(--border-base) 62%, var(--text-primary));
|
||||
|
||||
/* Text tokens */
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #666666;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #475569;
|
||||
--text-inverted: #ffffff;
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
/* Accent tokens */
|
||||
--accent-primary: #0066ff;
|
||||
@@ -27,13 +30,13 @@
|
||||
--status-warning: #ff9800;
|
||||
|
||||
/* Message-specific tokens */
|
||||
--message-user-bg: var(--surface-secondary);
|
||||
--message-user-bg: color-mix(in oklab, var(--surface-secondary) 88%, var(--message-user-border));
|
||||
--message-user-border: #2196f3;
|
||||
--message-assistant-bg: var(--message-tool-bg);
|
||||
--message-assistant-border: #f59e0b;
|
||||
|
||||
--message-tool-bg: #f8f9fa;
|
||||
--message-tool-border: #6c757d;
|
||||
--message-tool-bg: #eef2f7;
|
||||
--message-tool-border: #64748b;
|
||||
|
||||
/* Session list selection tints */
|
||||
--session-user-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-user-border));
|
||||
@@ -56,6 +59,7 @@
|
||||
--attachment-chip-ring: rgba(0, 102, 255, 0.1);
|
||||
--badge-neutral-bg: rgba(0, 102, 255, 0.05);
|
||||
--badge-neutral-text: #0066ff;
|
||||
--badge-success-bg: rgba(76, 175, 80, 0.12);
|
||||
--status-ready-fg: #16a34a;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.1);
|
||||
--status-starting-fg: #ca8a04;
|
||||
@@ -71,11 +75,15 @@
|
||||
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
--folder-card-radius: 16px;
|
||||
--dropdown-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||
--dropdown-highlight-text: var(--text-inverted);
|
||||
--dropdown-highlight-text: var(--text-primary);
|
||||
--selection-highlight-bg: rgba(0, 102, 255, 0.12);
|
||||
--selection-highlight-strong-bg: rgba(0, 102, 255, 0.18);
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.5);
|
||||
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
--panel-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
|
||||
--panel-shadow-strong: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
--popover-shadow: 0 16px 40px rgba(0, 0, 0, 0.18);
|
||||
--code-block-header-bg: color-mix(in oklab, var(--surface-secondary) 78%, var(--text-primary));
|
||||
--message-error-bg: rgba(244, 67, 54, 0.1);
|
||||
--message-error-bg-strong: rgba(244, 67, 54, 0.15);
|
||||
--danger-soft-bg: rgba(239, 68, 68, 0.1);
|
||||
@@ -86,6 +94,19 @@
|
||||
--log-level-default: var(--text-primary);
|
||||
--focus-ring-color: var(--accent-primary);
|
||||
--focus-ring-offset: var(--surface-base);
|
||||
--control-ghost-bg: color-mix(in oklab, var(--text-primary) 6%, transparent);
|
||||
--status-success-ring: color-mix(in oklab, var(--status-success) 45%, transparent);
|
||||
--border-critical: var(--status-error);
|
||||
|
||||
/* Message timeline active segment (light theme should be a light tint). */
|
||||
--timeline-segment-active-bg: #b7e6d6;
|
||||
--timeline-segment-active-text: #032f23;
|
||||
--timeline-segment-active-ring: inset 0 0 0 1px rgba(3, 47, 35, 0.28);
|
||||
|
||||
--button-danger-bg: rgba(239, 68, 68, 0.85);
|
||||
--button-danger-hover-bg: rgba(239, 68, 68, 0.9);
|
||||
--button-danger-active-bg: rgba(239, 68, 68, 1);
|
||||
--button-danger-text: var(--text-inverted);
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
@@ -151,7 +172,8 @@
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme]) {
|
||||
color-scheme: dark;
|
||||
/* Surface tokens */
|
||||
--surface-base: #1a1a1a;
|
||||
--surface-secondary: #2a2a2a;
|
||||
@@ -163,12 +185,14 @@
|
||||
--border-base: #3a3a3a;
|
||||
--border-secondary: #3a3a3a;
|
||||
--border-muted: #3a3a3a;
|
||||
--border-strong: var(--border-base);
|
||||
|
||||
/* Text tokens */
|
||||
--text-primary: #cfd4dc;
|
||||
--text-secondary: #999999;
|
||||
--text-muted: #999999;
|
||||
--text-inverted: #1a1a1a;
|
||||
--text-on-accent: #f5f6f8;
|
||||
|
||||
/* Accent tokens */
|
||||
--accent-primary: #0080ff;
|
||||
@@ -206,6 +230,7 @@
|
||||
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
||||
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
|
||||
--badge-neutral-text: #0080ff;
|
||||
--badge-success-bg: rgba(76, 175, 80, 0.22);
|
||||
--status-ready-fg: #22c55e;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.2);
|
||||
--status-starting-fg: #facc15;
|
||||
@@ -225,6 +250,15 @@
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
--panel-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
--panel-shadow-strong: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
--popover-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
|
||||
--code-block-header-bg: var(--surface-secondary);
|
||||
--border-critical: var(--status-error);
|
||||
--timeline-segment-active-bg: #0f5b44;
|
||||
--timeline-segment-active-text: #ffffff;
|
||||
--timeline-segment-active-ring: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
--button-danger-text: var(--text-inverted);
|
||||
--button-primary-bg: #3f3f46;
|
||||
--button-primary-hover-bg: #52525b;
|
||||
--button-primary-text: #f5f6f8;
|
||||
@@ -306,6 +340,7 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
/* Surface tokens */
|
||||
--surface-base: #1a1a1a;
|
||||
--surface-secondary: #2a2a2a;
|
||||
@@ -317,12 +352,14 @@
|
||||
--border-base: #3a3a3a;
|
||||
--border-secondary: #3a3a3a;
|
||||
--border-muted: #3a3a3a;
|
||||
--border-strong: var(--border-base);
|
||||
|
||||
/* Text tokens */
|
||||
--text-primary: #cfd4dc;
|
||||
--text-secondary: #999999;
|
||||
--text-muted: #999999;
|
||||
--text-inverted: #1a1a1a;
|
||||
--text-on-accent: #f5f6f8;
|
||||
|
||||
/* Accent tokens */
|
||||
--accent-primary: #0080ff;
|
||||
@@ -359,6 +396,7 @@
|
||||
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
||||
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
|
||||
--badge-neutral-text: #0080ff;
|
||||
--badge-success-bg: rgba(76, 175, 80, 0.22);
|
||||
--status-ready-fg: #22c55e;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.2);
|
||||
--status-starting-fg: #facc15;
|
||||
@@ -379,6 +417,15 @@
|
||||
--selection-highlight-strong-bg: rgba(0, 128, 255, 0.28);
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.6);
|
||||
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.35);
|
||||
--panel-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
--panel-shadow-strong: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
--popover-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
|
||||
--code-block-header-bg: var(--surface-secondary);
|
||||
--border-critical: var(--status-error);
|
||||
--timeline-segment-active-bg: #0f5b44;
|
||||
--timeline-segment-active-text: #ffffff;
|
||||
--timeline-segment-active-ring: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
--button-danger-text: var(--text-inverted);
|
||||
--message-error-bg: rgba(244, 67, 54, 0.12);
|
||||
--message-error-bg-strong: rgba(244, 67, 54, 0.2);
|
||||
--danger-soft-bg: rgba(244, 67, 54, 0.16);
|
||||
@@ -392,6 +439,18 @@
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
--button-primary-bg: #3f3f46;
|
||||
--button-primary-hover-bg: #52525b;
|
||||
--button-primary-text: #f5f6f8;
|
||||
--tab-active-bg: #3f3f46;
|
||||
--tab-active-hover-bg: #52525b;
|
||||
--tab-active-text: #f5f6f8;
|
||||
--tab-inactive-bg: #2a2a31;
|
||||
--tab-inactive-hover-bg: #3f3f46;
|
||||
--tab-inactive-text: #d4d4d8;
|
||||
--new-tab-bg: #3f3f46;
|
||||
--new-tab-hover-bg: #52525b;
|
||||
--new-tab-text: #f5f6f8;
|
||||
|
||||
/* Layout & spacing tokens */
|
||||
--space-2xs: 2px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "fs"
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
import { resolve } from "path"
|
||||
|
||||
const uiPackageJson = JSON.parse(fs.readFileSync(resolve(__dirname, "package.json"), "utf-8")) as { version?: string }
|
||||
@@ -20,6 +21,55 @@ export default defineConfig({
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prepare-pwa-source-icon",
|
||||
apply: "build",
|
||||
buildStart() {
|
||||
// vite-pwa-assets requires the source image inside root/public/
|
||||
const source = resolve(__dirname, "src/images/CodeNomad-Icon.png")
|
||||
const publicDir = resolve(__dirname, "src/renderer/public")
|
||||
const dest = resolve(publicDir, "logo.png")
|
||||
fs.mkdirSync(publicDir, { recursive: true })
|
||||
fs.copyFileSync(source, dest)
|
||||
},
|
||||
},
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "auto",
|
||||
pwaAssets: {
|
||||
preset: "minimal-2023",
|
||||
image: "public/logo.png",
|
||||
},
|
||||
manifest: {
|
||||
name: "CodeNomad",
|
||||
short_name: "CodeNomad",
|
||||
id: "/",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
display_override: ["window-controls-overlay", "standalone"],
|
||||
background_color: "#1a1a1a",
|
||||
theme_color: "#1a1a1a",
|
||||
},
|
||||
workbox: {
|
||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||
navigateFallback: null,
|
||||
// Only cache static UI assets; never cache API traffic.
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url, request }) => {
|
||||
if (url.pathname.startsWith("/api/")) return false
|
||||
return ["script", "style", "image", "font"].includes(request.destination)
|
||||
},
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "asset-cache",
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
css: {
|
||||
postcss: "./postcss.config.js",
|
||||
|
||||
Reference in New Issue
Block a user