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",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"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 { app } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
private bootstrapToken: string | null = null
|
private bootstrapToken: string | null = null
|
||||||
|
private requestedStop = false
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
this.bootstrapToken = null
|
this.bootstrapToken = null
|
||||||
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
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)}`)
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env,
|
env,
|
||||||
shell: false,
|
shell: false,
|
||||||
|
detached,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
@@ -175,12 +179,89 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const killTimeout = setTimeout(() => {
|
const killTimeout = setTimeout(() => {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||||
)
|
)
|
||||||
child.kill("SIGKILL")
|
sendStopSignal("SIGKILL")
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
child.on("exit", () => {
|
||||||
@@ -191,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
resolve()
|
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() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
|
const pid = this.child.pid
|
||||||
|
if (pid && process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
this.child.kill("SIGKILL")
|
this.child.kill("SIGKILL")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.child.kill("SIGKILL")
|
||||||
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"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.).
|
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.
|
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
|
### Data Storage
|
||||||
- **Config**: `~/.config/codenomad/config.json`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
- **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",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.vite/
|
.vite/
|
||||||
|
src/renderer/public/logo.png
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -30,11 +30,13 @@
|
|||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"autoprefixer": "10.4.21",
|
"autoprefixer": "10.4.21",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"tailwindcss": "3",
|
"tailwindcss": "3",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
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 MessageItem from "./message-item"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
@@ -390,9 +390,10 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!taskLocation()}
|
disabled={!taskLocation()}
|
||||||
onClick={handleGoToTaskSession}
|
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>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -401,9 +402,10 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={deleteDisabled()}
|
disabled={deleteDisabled()}
|
||||||
onClick={handleDeleteToolPart}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { For, Show, createSignal } from "solid-js"
|
import { For, Show, createSignal } from "solid-js"
|
||||||
|
import { Copy, Split, Trash2, Undo } from "lucide-solid"
|
||||||
import type { MessageInfo, ClientPart } from "../types/message"
|
import type { MessageInfo, ClientPart } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
@@ -159,6 +160,8 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyLabel = () => (copied() ? t("messageItem.actions.copied") : t("messageItem.actions.copy"))
|
||||||
|
|
||||||
const getRawContent = () => {
|
const getRawContent = () => {
|
||||||
return props.parts
|
return props.parts
|
||||||
.filter(part => part.type === "text")
|
.filter(part => part.type === "text")
|
||||||
@@ -278,31 +281,29 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleRevert}
|
onClick={handleRevert}
|
||||||
title={t("messageItem.actions.revertTitle")}
|
title={t("messageItem.actions.revert")}
|
||||||
aria-label={t("messageItem.actions.revertTitle")}
|
aria-label={t("messageItem.actions.revert")}
|
||||||
>
|
>
|
||||||
{t("messageItem.actions.revert")}
|
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.onFork}>
|
<Show when={props.onFork}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={() => props.onFork?.(props.record.id)}
|
onClick={() => props.onFork?.(props.record.id)}
|
||||||
title={t("messageItem.actions.forkTitle")}
|
title={t("messageItem.actions.fork")}
|
||||||
aria-label={t("messageItem.actions.forkTitle")}
|
aria-label={t("messageItem.actions.fork")}
|
||||||
>
|
>
|
||||||
{t("messageItem.actions.fork")}
|
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title={t("messageItem.actions.copyTitle")}
|
title={copyLabel()}
|
||||||
aria-label={t("messageItem.actions.copyTitle")}
|
aria-label={copyLabel()}
|
||||||
>
|
>
|
||||||
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
{t("messageItem.actions.copied")}
|
|
||||||
</Show>
|
|
||||||
</button>
|
</button>
|
||||||
<Show when={deletableTextPartId()}>
|
<Show when={deletableTextPartId()}>
|
||||||
{(partId) => (
|
{(partId) => (
|
||||||
@@ -310,10 +311,10 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={() => void handleDeletePart(partId())}
|
onClick={() => void handleDeletePart(partId())}
|
||||||
disabled={isDeletingPart(partId())}
|
disabled={isDeletingPart(partId())}
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||||
aria-label={t("messagePart.actions.deleteTitle")}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -324,12 +325,10 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title={t("messageItem.actions.copyTitle")}
|
title={copyLabel()}
|
||||||
aria-label={t("messageItem.actions.copyTitle")}
|
aria-label={copyLabel()}
|
||||||
>
|
>
|
||||||
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
{t("messageItem.actions.copied")}
|
|
||||||
</Show>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={deletableTextPartId()}>
|
<Show when={deletableTextPartId()}>
|
||||||
@@ -338,10 +337,10 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={() => void handleDeletePart(partId())}
|
onClick={() => void handleDeletePart(partId())}
|
||||||
disabled={isDeletingPart(partId())}
|
disabled={isDeletingPart(partId())}
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||||
aria-label={t("messagePart.actions.deleteTitle")}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { getSessionInfo } from "../stores/sessions"
|
|||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
const SCROLL_SCOPE = "session"
|
const SCROLL_SCOPE = "session"
|
||||||
@@ -375,7 +377,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
||||||
const shellRect = shell.getBoundingClientRect()
|
const shellRect = shell.getBoundingClientRect()
|
||||||
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
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)
|
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
||||||
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
||||||
}
|
}
|
||||||
@@ -395,6 +399,24 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function handleContentRendered() {
|
||||||
if (props.loading) {
|
if (props.loading) {
|
||||||
return
|
return
|
||||||
@@ -835,6 +857,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||||
{t("messageSection.quote.addAsCode")}
|
{t("messageSection.quote.addAsCode")}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
|
||||||
|
{t("messageSection.quote.copy")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1021,7 +1021,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||||
if (!blockquote) return
|
if (!blockquote) return
|
||||||
|
|
||||||
insertBlockContent(`${blockquote}\n\n`)
|
insertBlockContent(`${blockquote}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertCodeSelection(rawText: string) {
|
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 { Expand } from "lucide-solid"
|
||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
@@ -112,6 +112,43 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
if (!props.isActive) return
|
if (!props.isActive) return
|
||||||
scheduleScrollToBottom()
|
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
|
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
|
import { Copy } from "lucide-solid"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||||
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import type { PermissionRequestLike } from "../types/permission"
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionSessionId } from "../types/permission"
|
import { getPermissionSessionId } from "../types/permission"
|
||||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
@@ -59,6 +61,11 @@ interface ToolCallProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
onContentRendered?: () => void
|
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 diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||||
|
|
||||||
const defaultExpandedForTool = createMemo(() => {
|
const defaultExpandedForTool = createMemo(() => {
|
||||||
|
if (props.forceCollapsed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const prefExpanded = toolOutputDefaultExpanded()
|
const prefExpanded = toolOutputDefaultExpanded()
|
||||||
const toolName = toolCallMemo()?.tool || ""
|
const toolName = toolCallMemo()?.tool || ""
|
||||||
if (toolName === "read") {
|
if (toolName === "read") {
|
||||||
@@ -575,12 +585,29 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
toolCall: toolCallMemo,
|
toolCall: toolCallMemo,
|
||||||
toolState,
|
toolState,
|
||||||
toolName,
|
toolName,
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
sessionId: props.sessionId,
|
||||||
t,
|
t,
|
||||||
messageVersion: messageVersionAccessor,
|
messageVersion: messageVersionAccessor,
|
||||||
partVersion: partVersionAccessor,
|
partVersion: partVersionAccessor,
|
||||||
renderMarkdown: renderMarkdownContent,
|
renderMarkdown: renderMarkdownContent,
|
||||||
renderAnsi: renderAnsiContent,
|
renderAnsi: renderAnsiContent,
|
||||||
renderDiff: renderDiffContent,
|
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,
|
scrollHelpers,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +661,19 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return getToolName(currentTool)
|
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 = () => {
|
const renderToolBody = () => {
|
||||||
return renderer().renderBody(rendererContext)
|
return renderer().renderBody(rendererContext)
|
||||||
}
|
}
|
||||||
@@ -737,17 +777,33 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}}
|
}}
|
||||||
class={`tool-call ${combinedStatusClass()}`}
|
class={`tool-call ${combinedStatusClass()}`}
|
||||||
>
|
>
|
||||||
|
<div class="tool-call-header">
|
||||||
<button
|
<button
|
||||||
class="tool-call-header"
|
type="button"
|
||||||
|
class="tool-call-header-toggle"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-expanded={expanded()}
|
aria-expanded={expanded()}
|
||||||
data-status-icon={statusIcon()}
|
|
||||||
>
|
>
|
||||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||||
{renderToolTitle()}
|
{headerText()}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{expanded() && (
|
{expanded() && (
|
||||||
<div class="tool-call-details">
|
<div class="tool-call-details">
|
||||||
{renderToolBody()}
|
{renderToolBody()}
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
return Array.isArray(draft) ? draft : []
|
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[]) => {
|
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||||
if (!props.active()) return
|
if (!props.active()) return
|
||||||
props.setDraftAnswers((prev) => {
|
props.setDraftAnswers((prev) => {
|
||||||
@@ -170,22 +183,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={isVisible() && questions().length > 0}>
|
<Show when={isVisible() && questions().length > 0}>
|
||||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
<div
|
||||||
<div class="tool-call-permission-header">
|
class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
|
||||||
<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-body">
|
<div class="tool-call-permission-body">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-2">
|
||||||
<For each={questions()}>
|
<For each={questions()}>
|
||||||
{(q, index) => {
|
{(q, index) => {
|
||||||
const i = () => index()
|
const i = () => index()
|
||||||
@@ -199,9 +201,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
const customChecked = () => customValue().length > 0
|
const customChecked = () => customValue().length > 0
|
||||||
|
|
||||||
return (
|
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="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>
|
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={multi()}>
|
<Show when={multi()}>
|
||||||
@@ -209,7 +211,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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">
|
<div class="mt-3 flex flex-col gap-1">
|
||||||
<For each={q?.options ?? []}>
|
<For each={q?.options ?? []}>
|
||||||
@@ -226,12 +228,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
}}
|
}}
|
||||||
type={inputType()}
|
type={inputType()}
|
||||||
name={groupName()}
|
name={groupName()}
|
||||||
|
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||||
checked={checked()}
|
checked={checked()}
|
||||||
disabled={!props.active() || props.submitting()}
|
disabled={!props.active() || props.submitting()}
|
||||||
onChange={() => toggleOption(i(), opt.label)}
|
onChange={() => toggleOption(i(), opt.label)}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col">
|
<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 class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -249,6 +252,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
}}
|
}}
|
||||||
type={inputType()}
|
type={inputType()}
|
||||||
name={groupName()}
|
name={groupName()}
|
||||||
|
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||||
checked={customChecked()}
|
checked={customChecked()}
|
||||||
disabled={!props.active() || props.submitting()}
|
disabled={!props.active() || props.submitting()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -266,9 +270,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
<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
|
<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"
|
type="text"
|
||||||
placeholder={t("toolCall.question.custom.placeholder")}
|
placeholder={t("toolCall.question.custom.placeholder")}
|
||||||
disabled={!props.active() || props.submitting()}
|
disabled={!props.active() || props.submitting()}
|
||||||
@@ -296,7 +300,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={props.active()}>
|
<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">
|
<div class="tool-call-permission-buttons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -330,7 +334,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!props.active() && props.request()}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</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 { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
import { resolveTitleForTool } from "../tool-title"
|
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 {
|
interface TaskSummaryItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,6 +17,70 @@ interface TaskSummaryItem {
|
|||||||
title?: string
|
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 {
|
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||||
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||||
return status
|
return status
|
||||||
@@ -78,7 +145,63 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
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 promptContent = createMemo(() => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return null
|
if (!state) return null
|
||||||
@@ -123,7 +246,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const legacyItems = createMemo(() => {
|
||||||
// Track the reactive change points so we only recompute when the part/message changes
|
// Track the reactive change points so we only recompute when the part/message changes
|
||||||
messageVersion?.()
|
messageVersion?.()
|
||||||
partVersion?.()
|
partVersion?.()
|
||||||
@@ -131,6 +254,9 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return []
|
if (!state) return []
|
||||||
|
|
||||||
|
// Prefer deriving steps from the child session when loaded.
|
||||||
|
if (childSessionLoaded()) return []
|
||||||
|
|
||||||
const { metadata } = readToolStatePayload(state)
|
const { metadata } = readToolStatePayload(state)
|
||||||
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
||||||
|
|
||||||
@@ -167,13 +293,18 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={items().length > 0}>
|
<Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
|
||||||
<section class="tool-call-task-section">
|
<section class="tool-call-task-section">
|
||||||
<header class="tool-call-task-section-header">
|
<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-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>
|
</header>
|
||||||
<div class="tool-call-task-section-body">
|
<div class="tool-call-task-section-body">
|
||||||
|
<Show
|
||||||
|
when={childToolKeys().length > 0}
|
||||||
|
fallback={
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-task-container"
|
class="message-text tool-call-markdown tool-call-task-container"
|
||||||
ref={scrollHelpers?.registerContainer}
|
ref={scrollHelpers?.registerContainer}
|
||||||
@@ -182,7 +313,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="tool-call-task-summary">
|
<div class="tool-call-task-summary">
|
||||||
<For each={items()}>
|
<For each={legacyItems()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const icon = getToolIcon(item.tool)
|
const icon = getToolIcon(item.tool)
|
||||||
const description = describeToolTitle(item)
|
const description = describeToolTitle(item)
|
||||||
@@ -212,6 +343,34 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
</div>
|
</div>
|
||||||
{scrollHelpers?.renderSentinel?.()}
|
{scrollHelpers?.renderSentinel?.()}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -74,12 +74,15 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
|||||||
toolCall: toolCallAccessor,
|
toolCall: toolCallAccessor,
|
||||||
toolState: toolStateAccessor,
|
toolState: toolStateAccessor,
|
||||||
toolName: toolNameAccessor,
|
toolName: toolNameAccessor,
|
||||||
|
instanceId: "",
|
||||||
|
sessionId: "",
|
||||||
t,
|
t,
|
||||||
messageVersion: messageVersionAccessor,
|
messageVersion: messageVersionAccessor,
|
||||||
partVersion: partVersionAccessor,
|
partVersion: partVersionAccessor,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
renderAnsi,
|
renderAnsi,
|
||||||
renderDiff,
|
renderDiff,
|
||||||
|
renderToolCall: () => null,
|
||||||
scrollHelpers: undefined,
|
scrollHelpers: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,12 +53,26 @@ export interface ToolRendererContext {
|
|||||||
toolCall: Accessor<ToolCallPart>
|
toolCall: Accessor<ToolCallPart>
|
||||||
toolState: Accessor<ToolState | undefined>
|
toolState: Accessor<ToolState | undefined>
|
||||||
toolName: Accessor<string>
|
toolName: Accessor<string>
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
t: (key: string, params?: Record<string, unknown>) => string
|
t: (key: string, params?: Record<string, unknown>) => string
|
||||||
messageVersion?: Accessor<number | undefined>
|
messageVersion?: Accessor<number | undefined>
|
||||||
partVersion?: Accessor<number | undefined>
|
partVersion?: Accessor<number | undefined>
|
||||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||||
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
||||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): 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
|
scrollHelpers?: ToolScrollHelpers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
|||||||
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||||
"messageSection.quote.addAsQuote": "Add as quote",
|
"messageSection.quote.addAsQuote": "Add as quote",
|
||||||
"messageSection.quote.addAsCode": "Add as code",
|
"messageSection.quote.addAsCode": "Add as code",
|
||||||
|
"messageSection.quote.copy": "Copy",
|
||||||
|
"messageSection.quote.copied": "Copied!",
|
||||||
|
"messageSection.quote.copyFailed": "Copy failed",
|
||||||
|
|
||||||
"messageTimeline.ariaLabel": "Message timeline",
|
"messageTimeline.ariaLabel": "Message timeline",
|
||||||
"messageTimeline.segment.user.label": "You",
|
"messageTimeline.segment.user.label": "You",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
|||||||
"toolCall.pending.waitingToRun": "Waiting to run...",
|
"toolCall.pending.waitingToRun": "Waiting to run...",
|
||||||
"toolCall.error.label": "Error:",
|
"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": "Diff",
|
||||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||||
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
|||||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||||
"messageSection.quote.addAsCode": "Añadir como código",
|
"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.ariaLabel": "Línea de tiempo de mensajes",
|
||||||
"messageTimeline.segment.user.label": "Tú",
|
"messageTimeline.segment.user.label": "Tú",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
|||||||
"toolCall.pending.waitingToRun": "Esperando para ejecutar...",
|
"toolCall.pending.waitingToRun": "Esperando para ejecutar...",
|
||||||
"toolCall.error.label": "Error:",
|
"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": "Diff",
|
||||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||||
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
|||||||
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
||||||
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
||||||
"messageSection.quote.addAsCode": "Ajouter en code",
|
"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.ariaLabel": "Chronologie des messages",
|
||||||
"messageTimeline.segment.user.label": "Vous",
|
"messageTimeline.segment.user.label": "Vous",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
|||||||
"toolCall.pending.waitingToRun": "En attente d'exécution...",
|
"toolCall.pending.waitingToRun": "En attente d'exécution...",
|
||||||
"toolCall.error.label": "Erreur :",
|
"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": "Diff",
|
||||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
|||||||
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
||||||
"messageSection.quote.addAsQuote": "引用として追加",
|
"messageSection.quote.addAsQuote": "引用として追加",
|
||||||
"messageSection.quote.addAsCode": "コードとして追加",
|
"messageSection.quote.addAsCode": "コードとして追加",
|
||||||
|
"messageSection.quote.copy": "コピー",
|
||||||
|
"messageSection.quote.copied": "コピーしました",
|
||||||
|
"messageSection.quote.copyFailed": "コピーできませんでした",
|
||||||
|
|
||||||
"messageTimeline.ariaLabel": "メッセージタイムライン",
|
"messageTimeline.ariaLabel": "メッセージタイムライン",
|
||||||
"messageTimeline.segment.user.label": "あなた",
|
"messageTimeline.segment.user.label": "あなた",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
|||||||
"toolCall.pending.waitingToRun": "実行待ち...",
|
"toolCall.pending.waitingToRun": "実行待ち...",
|
||||||
"toolCall.error.label": "エラー:",
|
"toolCall.error.label": "エラー:",
|
||||||
|
|
||||||
|
"toolCall.header.copyTitle": "Copy tool call title",
|
||||||
|
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||||
|
|
||||||
"toolCall.diff.label": "Diff",
|
"toolCall.diff.label": "Diff",
|
||||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||||
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
|||||||
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
||||||
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
||||||
"messageSection.quote.addAsCode": "Добавить как код",
|
"messageSection.quote.addAsCode": "Добавить как код",
|
||||||
|
"messageSection.quote.copy": "Копировать",
|
||||||
|
"messageSection.quote.copied": "Скопировано!",
|
||||||
|
"messageSection.quote.copyFailed": "Не удалось скопировать",
|
||||||
|
|
||||||
"messageTimeline.ariaLabel": "Таймлайн сообщений",
|
"messageTimeline.ariaLabel": "Таймлайн сообщений",
|
||||||
"messageTimeline.segment.user.label": "Вы",
|
"messageTimeline.segment.user.label": "Вы",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
|||||||
"toolCall.pending.waitingToRun": "Ожидание запуска…",
|
"toolCall.pending.waitingToRun": "Ожидание запуска…",
|
||||||
"toolCall.error.label": "Ошибка:",
|
"toolCall.error.label": "Ошибка:",
|
||||||
|
|
||||||
|
"toolCall.header.copyTitle": "Copy tool call title",
|
||||||
|
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||||
|
|
||||||
"toolCall.diff.label": "Diff",
|
"toolCall.diff.label": "Diff",
|
||||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||||
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
|||||||
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
||||||
"messageSection.quote.addAsQuote": "作为引用添加",
|
"messageSection.quote.addAsQuote": "作为引用添加",
|
||||||
"messageSection.quote.addAsCode": "作为代码添加",
|
"messageSection.quote.addAsCode": "作为代码添加",
|
||||||
|
"messageSection.quote.copy": "复制",
|
||||||
|
"messageSection.quote.copied": "已复制!",
|
||||||
|
"messageSection.quote.copyFailed": "无法复制",
|
||||||
|
|
||||||
"messageTimeline.ariaLabel": "消息时间线",
|
"messageTimeline.ariaLabel": "消息时间线",
|
||||||
"messageTimeline.segment.user.label": "你",
|
"messageTimeline.segment.user.label": "你",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
|||||||
"toolCall.pending.waitingToRun": "等待运行...",
|
"toolCall.pending.waitingToRun": "等待运行...",
|
||||||
"toolCall.error.label": "错误:",
|
"toolCall.error.label": "错误:",
|
||||||
|
|
||||||
|
"toolCall.header.copyTitle": "Copy tool call title",
|
||||||
|
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||||
|
|
||||||
"toolCall.diff.label": "Diff",
|
"toolCall.diff.label": "Diff",
|
||||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||||
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
||||||
|
|||||||
@@ -166,17 +166,17 @@
|
|||||||
|
|
||||||
.stop-button {
|
.stop-button {
|
||||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
@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);
|
background-color: var(--button-danger-bg, rgba(239, 68, 68, 0.85));
|
||||||
color: var(--button-danger-text);
|
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-button:hover:not(:disabled) {
|
.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;
|
@apply opacity-95 scale-105;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-button:active:not(:disabled) {
|
.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;
|
@apply scale-95;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,36 +84,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-header {
|
.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;
|
@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-family: var(--font-family-mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-header::before {
|
.tool-call-header-toggle::before {
|
||||||
content: "▶";
|
content: "▶";
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-right: 0.35rem;
|
margin-right: 0.35rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-header[aria-expanded="true"]::before {
|
.tool-call-header-toggle[aria-expanded="true"]::before {
|
||||||
content: "▼";
|
content: "▼";
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-header::after {
|
.tool-call-header-toggle:hover {
|
||||||
content: attr(data-status-icon);
|
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;
|
font-size: 0.95rem;
|
||||||
margin-left: 0.5rem;
|
color: var(--text-secondary);
|
||||||
}
|
padding: 0 0.5rem;
|
||||||
|
|
||||||
.tool-call-header[data-status-icon=""]::after {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-header:hover {
|
|
||||||
background-color: var(--surface-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-summary {
|
.tool-call-summary {
|
||||||
@@ -306,6 +329,10 @@
|
|||||||
background-color: var(--message-tool-bg);
|
background-color: var(--message-tool-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-permission.tool-call-permission-answered {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-call-permission-header {
|
.tool-call-permission-header {
|
||||||
@apply flex items-center justify-between gap-3;
|
@apply flex items-center justify-between gap-3;
|
||||||
}
|
}
|
||||||
@@ -322,6 +349,7 @@
|
|||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||||
background-color: var(--surface-code);
|
background-color: var(--surface-code);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-permission-title code {
|
.tool-call-permission-title code {
|
||||||
|
|||||||
@@ -76,6 +76,42 @@
|
|||||||
margin: 0;
|
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 {
|
.tool-call-task-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -85,7 +121,7 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
line-height: 1.35;
|
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;
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
.tool-call-todo-checkbox[data-status="completed"] {
|
.tool-call-todo-checkbox[data-status="completed"] {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
border-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 {
|
.tool-call-todo-checkbox[data-status="completed"]::after {
|
||||||
@@ -155,5 +155,5 @@
|
|||||||
|
|
||||||
.tool-call-todo-item-active .tool-call-todo-tag {
|
.tool-call-todo-item-active .tool-call-todo-tag {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: var(--text-inverted);
|
color: var(--text-on-accent, #ffffff);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
--text-secondary: #334155;
|
--text-secondary: #334155;
|
||||||
--text-muted: #475569;
|
--text-muted: #475569;
|
||||||
--text-inverted: #ffffff;
|
--text-inverted: #ffffff;
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
|
||||||
/* Accent tokens */
|
/* Accent tokens */
|
||||||
--accent-primary: #0066ff;
|
--accent-primary: #0066ff;
|
||||||
@@ -102,10 +103,10 @@
|
|||||||
--timeline-segment-active-text: #032f23;
|
--timeline-segment-active-text: #032f23;
|
||||||
--timeline-segment-active-ring: inset 0 0 0 1px rgba(3, 47, 35, 0.28);
|
--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-bg: rgba(239, 68, 68, 0.85);
|
||||||
--button-danger-hover-bg: color-mix(in oklab, var(--status-error) 90%, var(--surface-base));
|
--button-danger-hover-bg: rgba(239, 68, 68, 0.9);
|
||||||
--button-danger-active-bg: color-mix(in oklab, var(--status-error) 95%, var(--surface-base));
|
--button-danger-active-bg: rgba(239, 68, 68, 1);
|
||||||
--button-danger-text: #ffffff;
|
--button-danger-text: var(--text-inverted);
|
||||||
--kbd-bg: var(--surface-secondary);
|
--kbd-bg: var(--surface-secondary);
|
||||||
--kbd-border: var(--border-base);
|
--kbd-border: var(--border-base);
|
||||||
--kbd-text: var(--text-primary);
|
--kbd-text: var(--text-primary);
|
||||||
@@ -191,6 +192,7 @@
|
|||||||
--text-secondary: #999999;
|
--text-secondary: #999999;
|
||||||
--text-muted: #999999;
|
--text-muted: #999999;
|
||||||
--text-inverted: #1a1a1a;
|
--text-inverted: #1a1a1a;
|
||||||
|
--text-on-accent: #f5f6f8;
|
||||||
|
|
||||||
/* Accent tokens */
|
/* Accent tokens */
|
||||||
--accent-primary: #0080ff;
|
--accent-primary: #0080ff;
|
||||||
@@ -256,7 +258,7 @@
|
|||||||
--timeline-segment-active-bg: #0f5b44;
|
--timeline-segment-active-bg: #0f5b44;
|
||||||
--timeline-segment-active-text: #ffffff;
|
--timeline-segment-active-text: #ffffff;
|
||||||
--timeline-segment-active-ring: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
--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-bg: #3f3f46;
|
||||||
--button-primary-hover-bg: #52525b;
|
--button-primary-hover-bg: #52525b;
|
||||||
--button-primary-text: #f5f6f8;
|
--button-primary-text: #f5f6f8;
|
||||||
@@ -357,6 +359,7 @@
|
|||||||
--text-secondary: #999999;
|
--text-secondary: #999999;
|
||||||
--text-muted: #999999;
|
--text-muted: #999999;
|
||||||
--text-inverted: #1a1a1a;
|
--text-inverted: #1a1a1a;
|
||||||
|
--text-on-accent: #f5f6f8;
|
||||||
|
|
||||||
/* Accent tokens */
|
/* Accent tokens */
|
||||||
--accent-primary: #0080ff;
|
--accent-primary: #0080ff;
|
||||||
@@ -422,7 +425,7 @@
|
|||||||
--timeline-segment-active-bg: #0f5b44;
|
--timeline-segment-active-bg: #0f5b44;
|
||||||
--timeline-segment-active-text: #ffffff;
|
--timeline-segment-active-text: #ffffff;
|
||||||
--timeline-segment-active-ring: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
--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: rgba(244, 67, 54, 0.12);
|
||||||
--message-error-bg-strong: rgba(244, 67, 54, 0.2);
|
--message-error-bg-strong: rgba(244, 67, 54, 0.2);
|
||||||
--danger-soft-bg: rgba(244, 67, 54, 0.16);
|
--danger-soft-bg: rgba(244, 67, 54, 0.16);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import solid from "vite-plugin-solid"
|
import solid from "vite-plugin-solid"
|
||||||
|
import { VitePWA } from "vite-plugin-pwa"
|
||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
|
|
||||||
const uiPackageJson = JSON.parse(fs.readFileSync(resolve(__dirname, "package.json"), "utf-8")) as { version?: string }
|
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: {
|
css: {
|
||||||
postcss: "./postcss.config.js",
|
postcss: "./postcss.config.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user