Compare commits

..

16 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
38 changed files with 5284 additions and 193 deletions

4630
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.9.4",
"version": "0.9.5",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

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,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.4",
"version": "0.9.5",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {

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.4",
"version": "0.9.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.9.4",
"version": "0.9.5",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.9.4",
"version": "0.9.5",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.9.4",
"version": "0.9.5",
"private": true,
"license": "MIT",
"scripts": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.9.4",
"version": "0.9.5",
"private": true,
"license": "MIT",
"type": "module",
@@ -30,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

@@ -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"
@@ -390,9 +390,10 @@ function ToolCallItem(props: ToolCallItemProps) {
type="button"
disabled={!taskLocation()}
onClick={handleGoToTaskSession}
title={!taskLocation() ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
title={t("messageBlock.tool.goToSession.label")}
aria-label={t("messageBlock.tool.goToSession.label")}
>
{t("messageBlock.tool.goToSession.label")}
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
@@ -401,9 +402,10 @@ function ToolCallItem(props: ToolCallItemProps) {
type="button"
disabled={deleteDisabled()}
onClick={handleDeleteToolPart}
title={t("messageBlock.tool.deletePart.title")}
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")}
>
{deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
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"
@@ -159,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")
@@ -278,31 +281,29 @@ 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) => (
@@ -310,10 +311,10 @@ export default function MessageItem(props: MessageItemProps) {
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={t("messagePart.actions.deleteTitle")}
aria-label={t("messagePart.actions.deleteTitle")}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
>
{isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
@@ -324,12 +325,10 @@ export default function MessageItem(props: MessageItemProps) {
<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()}>
@@ -338,10 +337,10 @@ export default function MessageItem(props: MessageItemProps) {
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={t("messagePart.actions.deleteTitle")}
aria-label={t("messagePart.actions.deleteTitle")}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
>
{isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>

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

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

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

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

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

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

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": "あなた",

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": "Вы",

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": "你",

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

@@ -166,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: var(--button-danger-bg);
color: var(--button-danger-text);
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: var(--button-danger-hover-bg);
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: var(--button-danger-active-bg);
background-color: var(--button-danger-active-bg, rgba(239, 68, 68, 1));
@apply scale-95;
}

View File

@@ -84,36 +84,59 @@
}
.tool-call-header {
@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::before {
.tool-call-header-toggle::before {
content: "▶";
font-size: 11px;
margin-right: 0.35rem;
color: var(--text-secondary);
}
.tool-call-header[aria-expanded="true"]::before {
.tool-call-header-toggle[aria-expanded="true"]::before {
content: "▼";
}
.tool-call-header::after {
content: attr(data-status-icon);
.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;
margin-left: 0.5rem;
}
.tool-call-header[data-status-icon=""]::after {
margin-left: 0;
}
.tool-call-header:hover {
background-color: var(--surface-hover);
color: var(--text-secondary);
padding: 0 0.5rem;
}
.tool-call-summary {
@@ -306,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;
}
@@ -322,6 +349,7 @@
border-radius: 0.375rem;
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 {

View File

@@ -76,6 +76,42 @@
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;
@@ -85,7 +121,7 @@
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

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

@@ -18,6 +18,7 @@
--text-secondary: #334155;
--text-muted: #475569;
--text-inverted: #ffffff;
--text-on-accent: #ffffff;
/* Accent tokens */
--accent-primary: #0066ff;
@@ -102,10 +103,10 @@
--timeline-segment-active-text: #032f23;
--timeline-segment-active-ring: inset 0 0 0 1px rgba(3, 47, 35, 0.28);
--button-danger-bg: color-mix(in oklab, var(--status-error) 85%, var(--surface-base));
--button-danger-hover-bg: color-mix(in oklab, var(--status-error) 90%, var(--surface-base));
--button-danger-active-bg: color-mix(in oklab, var(--status-error) 95%, var(--surface-base));
--button-danger-text: #ffffff;
--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);
@@ -191,6 +192,7 @@
--text-secondary: #999999;
--text-muted: #999999;
--text-inverted: #1a1a1a;
--text-on-accent: #f5f6f8;
/* Accent tokens */
--accent-primary: #0080ff;
@@ -256,7 +258,7 @@
--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: #ffffff;
--button-danger-text: var(--text-inverted);
--button-primary-bg: #3f3f46;
--button-primary-hover-bg: #52525b;
--button-primary-text: #f5f6f8;
@@ -357,6 +359,7 @@
--text-secondary: #999999;
--text-muted: #999999;
--text-inverted: #1a1a1a;
--text-on-accent: #f5f6f8;
/* Accent tokens */
--accent-primary: #0080ff;
@@ -422,7 +425,7 @@
--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: #ffffff;
--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);

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