Compare commits

...

30 Commits

Author SHA1 Message Date
Shantur Rathore
7b6ed88be4 fix(ui): integrate PWA build and avoid api caching
Move PWA config into the default Vite build, ensure the PWA icon source is generated, and restrict Workbox caching to static assets only. Update server UI build wiring and clarify TLS requirements in docs.
2026-02-07 21:33:14 +00:00
Jesper Derehag
99474955af feat(ui): add PWA support with vite-plugin-pwa
- Add vite.config.pwa.ts extending the base config with VitePWA plugin
- Generate PWA icons at build time from source logo via @vite-pwa/assets-generator
- Add web app manifest with name, theme color, display overrides
- Add Workbox runtime caching: NetworkFirst for API, CacheFirst for assets
- Set navigateFallback to null to preserve server-side auth redirects
- Server build uses build:pwa for PWA-enabled output; Electron/Tauri use
  the base build without PWA

Signed-off-by: Jesper Derehag <jderehag@hotmail.com>
2026-02-07 00:18:28 +01:00
Shantur Rathore
157fe9d6b4 feat(ui): switch message actions to icon buttons 2026-02-05 23:42:48 +00:00
Shantur Rathore
6c42b64466 feat(ui): copy tool call header title 2026-02-05 23:30:38 +00:00
Shantur Rathore
88605a4617 feat(ui): add copy option for selected text 2026-02-05 23:20:13 +00:00
Shantur Rathore
e8f8e7bd65 fix(ui): avoid trailing blank line after quote insert 2026-02-05 23:17:22 +00:00
Shantur Rathore
750a87ef45 fix(ui): render task steps from child session 2026-02-05 23:08:59 +00:00
Shantur Rathore
8fda9aed71 fix(ui): focus prompt on session activate 2026-02-04 14:20:50 +00:00
Shantur Rathore
7e1dab8384 fix(electron): stop server process tree on quit 2026-02-04 10:28:51 +00:00
Shantur Rathore
5b24f0cd40 fix(ui): tighten question tool layout
Remove the redundant header row, tighten spacing, and square off question cards. Also adjust answered question container styling to match tool call layout.
2026-02-04 00:34:40 +00:00
Shantur Rathore
a6b1f4ba19 fix(ui): improve question tool contrast
Make question tool prompt, labels, and the type pill use primary text color for readability in light mode, and bump the Q header line to text-sm.
2026-02-04 00:20:19 +00:00
Shantur Rathore
df02b7cdca fix(ui): repair question tool styling
Use token-backed surface/background classes for the question tool cards and ensure radio/checkbox inputs use accent-color so the view renders correctly in both light and dark themes.
2026-02-04 00:14:50 +00:00
Shantur Rathore
06b0d03c31 fix(ui): align stop button icon contrast
Use --text-inverted for stop button icon color in dark mode so it matches send button styling, with a safe fallback in CSS.
2026-02-03 22:22:47 +00:00
Shantur Rathore
fd22a5ed9d fix(ui): restore stop button styling
Avoid color-mix for the stop button danger palette so it renders consistently across runtimes; add safe rgba fallbacks for the background colors.
2026-02-03 22:15:03 +00:00
Shantur Rathore
86db407c0b fix(ui): restore tool call colors in dark mode
Use a dedicated --text-on-accent token for accent chips/checkmarks and tweak task list item surfaces so task/todo renderers keep contrast in dark mode.
2026-02-03 22:09:02 +00:00
Shantur Rathore
f1520be777 Bump version to 0.9.5 2026-02-03 22:01:41 +00:00
Shantur Rathore
8a91e04ff9 Bump to v0.9.4 2026-02-03 20:22:17 +00:00
Shantur Rathore
76b1134c95 fix(ui): apply theme before initial render 2026-02-03 20:12:02 +00:00
Shantur Rathore
d98d519fd3 feat(ui): persist theme preference
Persist system/light/dark theme mode in app config and default new installs to system so the UI follows OS theme unless overridden.
2026-02-03 19:42:24 +00:00
Shantur Rathore
02407e0f7a fix(ui): restore dark tab and tool output styling
Use tokenized border contrast so dark mode borders stay subtle, keep instance tab status dots vivid in dark themes, and adjust tool-call code block header background via a dedicated token.
2026-02-03 19:02:47 +00:00
Shantur Rathore
0261154a5e feat(ui): add delete action for message parts 2026-02-03 18:32:54 +00:00
Shantur Rathore
d2b68159be chore(opencode-config): bump @opencode-ai/plugin 2026-02-03 17:37:02 +00:00
Shantur Rathore
aab0692403 fix(ui): tune light mode contrast 2026-02-03 17:37:02 +00:00
Shantur Rathore
17a3e43ac7 feat(ui): add system/light/dark theme toggle
Add a 3-state theme toggle in folder selection and instance tabs, and update tokens/styles so light mode has readable contrast. Sync MUI surfaces and Shiki highlighting to CSS variables to prevent stale colors when switching themes.
2026-02-03 16:49:42 +00:00
Shantur Rathore
a2127a11ac fix(server): include symlink directories in listings
Fixes https://github.com/NeuralNomadsAI/CodeNomad/issues/106
2026-02-03 15:22:49 +00:00
Shantur Rathore
ea4c687125 chore: add MIT License 2026-02-03 15:08:24 +00:00
Shantur Rathore
de20b3adf3 fix(ui): allow collapsing active parent thread 2026-02-03 15:07:05 +00:00
Shantur Rathore
929e79befd chore(license): add MIT license
Clarifies usage and redistribution terms across the monorepo.
2026-02-02 11:22:49 +00:00
Shantur Rathore
3522d3dff5 fix(electron): quit on last window close 2026-01-31 11:24:56 +00:00
Shantur Rathore
1af01680ee feat(ui): add session sidebar search and bulk selection
Adds an optional session filter bar to the left sidebar with title search across parent/child sessions and a scoped Select All. Introduces multi-select checkboxes, bulk delete with clear selection controls, and confirmation dialogs for both single and bulk deletions using the existing alert dialog flow. Updates session i18n strings across supported locales.
2026-01-30 17:34:25 +00:00
72 changed files with 6580 additions and 427 deletions

21
LICENSE Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,7 @@
{
"name": "@codenomad/ui-host-worker",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"build:manifest": "node ./scripts/build-manifest.mjs",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
name = "codenomad-tauri"
version = "0.1.0"
edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }

View File

@@ -1,3 +1,4 @@
node_modules/
dist/
.vite/
src/renderer/public/logo.png

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "このセッションの新しいタイトルを設定します。",

View File

@@ -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 表示モード",

View File

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

View File

@@ -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": "Установите новое название для этой сессии.",

View File

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

View File

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

View File

@@ -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": "为此会话设置新标题。",

View File

@@ -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 视图模式",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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