Compare commits
16 Commits
v0.9.4
...
jderehag/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6ed88be4 | ||
|
|
99474955af | ||
|
|
157fe9d6b4 | ||
|
|
6c42b64466 | ||
|
|
88605a4617 | ||
|
|
e8f8e7bd65 | ||
|
|
750a87ef45 | ||
|
|
8fda9aed71 | ||
|
|
7e1dab8384 | ||
|
|
5b24f0cd40 | ||
|
|
a6b1f4ba19 | ||
|
|
df02b7cdca | ||
|
|
06b0d03c31 | ||
|
|
fd22a5ed9d | ||
|
|
86db407c0b | ||
|
|
f1520be777 |
4630
package-lock.json
generated
4630
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
@@ -109,11 +111,13 @@ export class CliProcessManager extends EventEmitter {
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
@@ -175,12 +179,89 @@ export class CliProcessManager extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
// Negative PID targets the process group (POSIX).
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryTaskkill = (force: boolean) => {
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("not found") || combined.includes("no running instance")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
|
||||
const groupOk = tryKillPosixGroup(signal)
|
||||
if (!groupOk) {
|
||||
tryKillSinglePid(signal)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
child.kill("SIGKILL")
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
@@ -191,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
if (isAlreadyExited()) {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
sendStopSignal("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,7 +294,16 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
const pid = this.child.pid
|
||||
if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -62,6 +62,18 @@ You can configure the server using flags or environment variables:
|
||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||
|
||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
|
||||
3. The app will open in a standalone window and appear in your OS app list.
|
||||
|
||||
> **TLS requirement**
|
||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||
> If you host CodeNomad on a remote machine, serve it behind a reverse proxy (e.g. Caddy, nginx) with a valid TLS certificate.
|
||||
> Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
|
||||
### Data Storage
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
src/renderer/public/logo.png
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getSessionInfo } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
@@ -375,7 +377,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
||||
const shellRect = shell.getBoundingClientRect()
|
||||
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
||||
const maxLeft = Math.max(shell.clientWidth - 180, 8)
|
||||
// Keep the popover within the stream shell. The quote popover currently
|
||||
// renders 3 actions; keep enough horizontal room for the pill.
|
||||
const maxLeft = Math.max(shell.clientWidth - 260, 8)
|
||||
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
||||
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
||||
}
|
||||
@@ -394,6 +398,24 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopySelectionRequest() {
|
||||
const info = quoteSelection()
|
||||
if (!info) return
|
||||
|
||||
const success = await copyToClipboard(info.text)
|
||||
showToastNotification({
|
||||
message: success ? t("messageSection.quote.copied") : t("messageSection.quote.copyFailed"),
|
||||
variant: success ? "success" : "error",
|
||||
duration: success ? 2000 : 6000,
|
||||
})
|
||||
|
||||
clearQuoteSelection()
|
||||
if (typeof window !== "undefined") {
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (props.loading) {
|
||||
@@ -835,6 +857,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||
{t("messageSection.quote.addAsCode")}
|
||||
</button>
|
||||
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
|
||||
{t("messageSection.quote.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1021,7 +1021,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||
if (!blockquote) return
|
||||
|
||||
insertBlockContent(`${blockquote}\n\n`)
|
||||
insertBlockContent(`${blockquote}\n`)
|
||||
}
|
||||
|
||||
function insertCodeSelection(rawText: string) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { Copy } from "lucide-solid"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionSessionId } from "../types/permission"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
@@ -59,6 +61,11 @@ interface ToolCallProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onContentRendered?: () => void
|
||||
/**
|
||||
* When true, tool call starts collapsed regardless of user preferences.
|
||||
* Users can still expand/collapse manually.
|
||||
*/
|
||||
forceCollapsed?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +149,9 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
|
||||
const defaultExpandedForTool = createMemo(() => {
|
||||
if (props.forceCollapsed) {
|
||||
return false
|
||||
}
|
||||
const prefExpanded = toolOutputDefaultExpanded()
|
||||
const toolName = toolCallMemo()?.tool || ""
|
||||
if (toolName === "read") {
|
||||
@@ -575,12 +585,29 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
toolCall: toolCallMemo,
|
||||
toolState,
|
||||
toolName,
|
||||
instanceId: props.instanceId,
|
||||
sessionId: props.sessionId,
|
||||
t,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown: renderMarkdownContent,
|
||||
renderAnsi: renderAnsiContent,
|
||||
renderDiff: renderDiffContent,
|
||||
renderToolCall: (options) => {
|
||||
if (!options?.toolCall) return null
|
||||
return (
|
||||
<ToolCall
|
||||
toolCall={options.toolCall}
|
||||
toolCallId={options.toolCall.id}
|
||||
messageId={options.messageId}
|
||||
messageVersion={options.messageVersion}
|
||||
partVersion={options.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={options.sessionId}
|
||||
forceCollapsed={options.forceCollapsed}
|
||||
/>
|
||||
)
|
||||
},
|
||||
scrollHelpers,
|
||||
}
|
||||
|
||||
@@ -634,6 +661,19 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return getToolName(currentTool)
|
||||
}
|
||||
|
||||
const headerText = createMemo(() => {
|
||||
// Keep this as a memo so copy always matches what's rendered.
|
||||
return renderToolTitle()
|
||||
})
|
||||
|
||||
const handleCopyHeader = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const text = headerText()
|
||||
if (!text) return
|
||||
await copyToClipboard(text)
|
||||
}
|
||||
|
||||
const renderToolBody = () => {
|
||||
return renderer().renderBody(rendererContext)
|
||||
}
|
||||
@@ -737,16 +777,32 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}}
|
||||
class={`tool-call ${combinedStatusClass()}`}
|
||||
>
|
||||
<button
|
||||
class="tool-call-header"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
data-status-icon={statusIcon()}
|
||||
>
|
||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||
{renderToolTitle()}
|
||||
<div class="tool-call-header">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
>
|
||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||
{headerText()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-copy"
|
||||
onClick={handleCopyHeader}
|
||||
aria-label={t("toolCall.header.copyAriaLabel")}
|
||||
title={t("toolCall.header.copyTitle")}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span class="tool-call-header-status" aria-hidden="true">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded() && (
|
||||
<div class="tool-call-details">
|
||||
|
||||
@@ -80,6 +80,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
return Array.isArray(draft) ? draft : []
|
||||
})
|
||||
|
||||
const hasFinalAnswers = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
if ((state as any)?.status === "completed") return true
|
||||
|
||||
const request = props.request()
|
||||
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer)
|
||||
if (Array.isArray(requestAnswers) && requestAnswers.length > 0) {
|
||||
return requestAnswers.every((row) => Array.isArray(row) && row.length > 0)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||
if (!props.active()) return
|
||||
props.setDraftAnswers((prev) => {
|
||||
@@ -170,22 +183,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
|
||||
return (
|
||||
<Show when={isVisible() && questions().length > 0}>
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">
|
||||
{props.active()
|
||||
? t("toolCall.question.status.required")
|
||||
: props.request()
|
||||
? t("toolCall.question.status.queued")
|
||||
: t("toolCall.question.status.questions")}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">
|
||||
{questions().length === 1 ? t("toolCall.question.type.one") : t("toolCall.question.type.other")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
|
||||
>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const i = () => index()
|
||||
@@ -199,9 +201,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
const customChecked = () => customValue().length > 0
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||
<div class="border border-base bg-surface-secondary p-3 text-primary">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs">
|
||||
<div class="text-sm text-primary">
|
||||
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
@@ -209,7 +211,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||
<div class="mt-1 text-sm font-medium text-primary">{q?.question}</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<For each={q?.options ?? []}>
|
||||
@@ -226,12 +228,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||
checked={checked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={() => toggleOption(i(), opt.label)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight">{opt.label}</div>
|
||||
<div class="text-sm leading-tight text-primary">{opt.label}</div>
|
||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -249,6 +252,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||
checked={customChecked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={(e) => {
|
||||
@@ -266,13 +270,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="text-sm leading-tight">{t("toolCall.question.custom.label")}</div>
|
||||
<div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
|
||||
<input
|
||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||
class="w-full rounded-md border border-base bg-surface-base px-2 py-1 text-sm text-primary"
|
||||
type="text"
|
||||
placeholder={t("toolCall.question.custom.placeholder")}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
onFocus={(e) => {
|
||||
if (!props.active()) return
|
||||
// Keep the radio/checkbox selected while editing.
|
||||
@@ -296,7 +300,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</For>
|
||||
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-actions px-3 pb-3">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -330,7 +334,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={!props.active() && props.request()}>
|
||||
<p class="tool-call-permission-queued-text">{t("toolCall.question.queuedText")}</p>
|
||||
<p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||
import { loadMessages } from "../../../stores/session-api"
|
||||
import { loading, messagesLoaded } from "../../../stores/session-state"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
id: string
|
||||
@@ -14,6 +17,70 @@ interface TaskSummaryItem {
|
||||
title?: string
|
||||
}
|
||||
|
||||
function extractSessionIdFromTaskState(state?: ToolState): string {
|
||||
if (!state) return ""
|
||||
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
|
||||
const directId = (metadata as any)?.sessionId ?? (metadata as any)?.sessionID
|
||||
return typeof directId === "string" ? directId : ""
|
||||
}
|
||||
|
||||
function splitToolKey(key: string): { messageId: string; partId: string } | null {
|
||||
const separator = "::"
|
||||
const index = key.lastIndexOf(separator)
|
||||
if (index <= 0) return null
|
||||
const messageId = key.slice(0, index)
|
||||
const partId = key.slice(index + separator.length)
|
||||
if (!messageId || !partId) return null
|
||||
return { messageId, partId }
|
||||
}
|
||||
|
||||
function TaskToolCallRow(props: {
|
||||
toolKey: string
|
||||
store: ReturnType<typeof messageStoreBus.getOrCreate>
|
||||
sessionId: string
|
||||
renderToolCall: NonNullable<import("../types").ToolRendererContext["renderToolCall"]>
|
||||
}) {
|
||||
const parts = createMemo(() => splitToolKey(props.toolKey))
|
||||
const messageId = createMemo(() => parts()?.messageId ?? "")
|
||||
const partId = createMemo(() => parts()?.partId ?? "")
|
||||
|
||||
const record = createMemo(() => {
|
||||
const id = messageId()
|
||||
if (!id) return undefined
|
||||
return props.store.getMessage(id)
|
||||
})
|
||||
|
||||
const partEntry = createMemo(() => {
|
||||
const rec = record()
|
||||
const pid = partId()
|
||||
if (!rec || !pid) return undefined
|
||||
return rec.parts?.[pid]
|
||||
})
|
||||
|
||||
const toolPart = createMemo(() => {
|
||||
const data = partEntry()?.data
|
||||
return data && (data as any).type === "tool" ? (data as any) : undefined
|
||||
})
|
||||
|
||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const rendered = createMemo(() => {
|
||||
const part = toolPart()
|
||||
if (!part) return null
|
||||
return props.renderToolCall({
|
||||
toolCall: part as any,
|
||||
messageId: messageId(),
|
||||
messageVersion: messageVersion(),
|
||||
partVersion: partVersion(),
|
||||
sessionId: props.sessionId,
|
||||
forceCollapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
return <>{rendered()}</>
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||
return status
|
||||
@@ -78,7 +145,63 @@ export const taskRenderer: ToolRenderer = {
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||
|
||||
const childSessionId = createMemo(() => {
|
||||
const state = toolState()
|
||||
return extractSessionIdFromTaskState(state)
|
||||
})
|
||||
|
||||
const childSessionLoaded = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return false
|
||||
const loadedForInstance = messagesLoaded().get(instanceId)
|
||||
return loadedForInstance?.has(id) ?? false
|
||||
})
|
||||
|
||||
const childSessionLoading = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return false
|
||||
const loadingSet = loading().loadingMessages.get(instanceId)
|
||||
return loadingSet?.has(id) ?? false
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return
|
||||
if (requestedChildLoad()) return
|
||||
if (childSessionLoaded()) return
|
||||
if (childSessionLoading()) return
|
||||
setRequestedChildLoad(true)
|
||||
void loadMessages(instanceId, id)
|
||||
})
|
||||
|
||||
const childToolKeys = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return [] as string[]
|
||||
if (!childSessionLoaded()) return [] as string[]
|
||||
|
||||
// React to session changes, but do the scan untracked to avoid
|
||||
// subscribing to every message/part node in the store.
|
||||
store.getSessionRevision(id)
|
||||
return untrack(() => {
|
||||
const messageIds = store.getSessionMessageIds(id)
|
||||
const keys: string[] = []
|
||||
for (const messageId of messageIds) {
|
||||
const record = store.getMessage(messageId)
|
||||
if (!record) continue
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts?.[partId]
|
||||
const data = entry?.data
|
||||
if (!data || (data as any).type !== "tool") continue
|
||||
keys.push(`${messageId}::${partId}`)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
})
|
||||
})
|
||||
const promptContent = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
@@ -123,7 +246,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
return null
|
||||
})
|
||||
|
||||
const items = createMemo(() => {
|
||||
const legacyItems = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
partVersion?.()
|
||||
@@ -131,6 +254,9 @@ export const taskRenderer: ToolRenderer = {
|
||||
const state = toolState()
|
||||
if (!state) return []
|
||||
|
||||
// Prefer deriving steps from the child session when loaded.
|
||||
if (childSessionLoaded()) return []
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
||||
|
||||
@@ -167,51 +293,84 @@ export const taskRenderer: ToolRenderer = {
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={items().length > 0}>
|
||||
<Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
|
||||
<span class="tool-call-task-section-meta">{t("toolCall.task.steps.count", { count: items().length })}</span>
|
||||
<span class="tool-call-task-section-meta">
|
||||
{t("toolCall.task.steps.count", { count: childToolKeys().length > 0 ? childToolKeys().length : legacyItems().length })}
|
||||
</span>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
<Show
|
||||
when={childToolKeys().length > 0}
|
||||
fallback={
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={legacyItems()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusKey = summarizeStatusLabel(status)
|
||||
const statusLabel = statusKey
|
||||
? t(`toolCall.status.${statusKey}`)
|
||||
: t("toolCall.status.unknown")
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusKey = summarizeStatusLabel(status)
|
||||
const statusLabel = statusKey
|
||||
? t(`toolCall.status.${statusKey}`)
|
||||
: t("toolCall.status.unknown")
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
@@ -74,12 +74,15 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||
toolCall: toolCallAccessor,
|
||||
toolState: toolStateAccessor,
|
||||
toolName: toolNameAccessor,
|
||||
instanceId: "",
|
||||
sessionId: "",
|
||||
t,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown,
|
||||
renderAnsi,
|
||||
renderDiff,
|
||||
renderToolCall: () => null,
|
||||
scrollHelpers: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,26 @@ export interface ToolRendererContext {
|
||||
toolCall: Accessor<ToolCallPart>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolName: Accessor<string>
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
messageVersion?: Accessor<number | undefined>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
|
||||
/**
|
||||
* Render another tool call inline. This is provided by the ToolCall shell
|
||||
* to avoid renderer-level imports that would create cyclic dependencies.
|
||||
*/
|
||||
renderToolCall?: (options: {
|
||||
toolCall: ToolCallPart
|
||||
messageId?: string
|
||||
messageVersion?: number
|
||||
partVersion?: number
|
||||
sessionId: string
|
||||
forceCollapsed?: boolean
|
||||
}) => JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Waiting to run...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||
"messageSection.quote.addAsCode": "Añadir como código",
|
||||
"messageSection.quote.copy": "Copiar",
|
||||
"messageSection.quote.copied": "¡Copiado!",
|
||||
"messageSection.quote.copyFailed": "No se pudo copiar",
|
||||
|
||||
"messageTimeline.ariaLabel": "Línea de tiempo de mensajes",
|
||||
"messageTimeline.segment.user.label": "Tú",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Esperando para ejecutar...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
||||
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
||||
"messageSection.quote.addAsCode": "Ajouter en code",
|
||||
"messageSection.quote.copy": "Copier",
|
||||
"messageSection.quote.copied": "Copié !",
|
||||
"messageSection.quote.copyFailed": "Impossible de copier",
|
||||
|
||||
"messageTimeline.ariaLabel": "Chronologie des messages",
|
||||
"messageTimeline.segment.user.label": "Vous",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "En attente d'exécution...",
|
||||
"toolCall.error.label": "Erreur :",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
||||
"messageSection.quote.addAsQuote": "引用として追加",
|
||||
"messageSection.quote.addAsCode": "コードとして追加",
|
||||
"messageSection.quote.copy": "コピー",
|
||||
"messageSection.quote.copied": "コピーしました",
|
||||
"messageSection.quote.copyFailed": "コピーできませんでした",
|
||||
|
||||
"messageTimeline.ariaLabel": "メッセージタイムライン",
|
||||
"messageTimeline.segment.user.label": "あなた",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "実行待ち...",
|
||||
"toolCall.error.label": "エラー:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
||||
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
||||
"messageSection.quote.addAsCode": "Добавить как код",
|
||||
"messageSection.quote.copy": "Копировать",
|
||||
"messageSection.quote.copied": "Скопировано!",
|
||||
"messageSection.quote.copyFailed": "Не удалось скопировать",
|
||||
|
||||
"messageTimeline.ariaLabel": "Таймлайн сообщений",
|
||||
"messageTimeline.segment.user.label": "Вы",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Ожидание запуска…",
|
||||
"toolCall.error.label": "Ошибка:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
||||
"messageSection.quote.addAsQuote": "作为引用添加",
|
||||
"messageSection.quote.addAsCode": "作为代码添加",
|
||||
"messageSection.quote.copy": "复制",
|
||||
"messageSection.quote.copied": "已复制!",
|
||||
"messageSection.quote.copyFailed": "无法复制",
|
||||
|
||||
"messageTimeline.ariaLabel": "消息时间线",
|
||||
"messageTimeline.segment.user.label": "你",
|
||||
|
||||
@@ -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 视图模式",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "fs"
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
import { resolve } from "path"
|
||||
|
||||
const uiPackageJson = JSON.parse(fs.readFileSync(resolve(__dirname, "package.json"), "utf-8")) as { version?: string }
|
||||
@@ -20,6 +21,55 @@ export default defineConfig({
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prepare-pwa-source-icon",
|
||||
apply: "build",
|
||||
buildStart() {
|
||||
// vite-pwa-assets requires the source image inside root/public/
|
||||
const source = resolve(__dirname, "src/images/CodeNomad-Icon.png")
|
||||
const publicDir = resolve(__dirname, "src/renderer/public")
|
||||
const dest = resolve(publicDir, "logo.png")
|
||||
fs.mkdirSync(publicDir, { recursive: true })
|
||||
fs.copyFileSync(source, dest)
|
||||
},
|
||||
},
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "auto",
|
||||
pwaAssets: {
|
||||
preset: "minimal-2023",
|
||||
image: "public/logo.png",
|
||||
},
|
||||
manifest: {
|
||||
name: "CodeNomad",
|
||||
short_name: "CodeNomad",
|
||||
id: "/",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
display_override: ["window-controls-overlay", "standalone"],
|
||||
background_color: "#1a1a1a",
|
||||
theme_color: "#1a1a1a",
|
||||
},
|
||||
workbox: {
|
||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||
navigateFallback: null,
|
||||
// Only cache static UI assets; never cache API traffic.
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url, request }) => {
|
||||
if (url.pathname.startsWith("/api/")) return false
|
||||
return ["script", "style", "image", "font"].includes(request.destination)
|
||||
},
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "asset-cache",
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
css: {
|
||||
postcss: "./postcss.config.js",
|
||||
|
||||
Reference in New Issue
Block a user