Compare commits
63 Commits
v0.9.0
...
jderehag/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6ed88be4 | ||
|
|
99474955af | ||
|
|
157fe9d6b4 | ||
|
|
6c42b64466 | ||
|
|
88605a4617 | ||
|
|
e8f8e7bd65 | ||
|
|
750a87ef45 | ||
|
|
8fda9aed71 | ||
|
|
7e1dab8384 | ||
|
|
5b24f0cd40 | ||
|
|
a6b1f4ba19 | ||
|
|
df02b7cdca | ||
|
|
06b0d03c31 | ||
|
|
fd22a5ed9d | ||
|
|
86db407c0b | ||
|
|
f1520be777 | ||
|
|
8a91e04ff9 | ||
|
|
76b1134c95 | ||
|
|
d98d519fd3 | ||
|
|
02407e0f7a | ||
|
|
0261154a5e | ||
|
|
d2b68159be | ||
|
|
aab0692403 | ||
|
|
17a3e43ac7 | ||
|
|
a2127a11ac | ||
|
|
ea4c687125 | ||
|
|
de20b3adf3 | ||
|
|
929e79befd | ||
|
|
3522d3dff5 | ||
|
|
1af01680ee | ||
|
|
67f5f830a3 | ||
|
|
81102cc6bf | ||
|
|
afa7243eab | ||
|
|
37b7c1e53c | ||
|
|
ba61ab79e2 | ||
|
|
37d075fbb3 | ||
|
|
2961d41be3 | ||
|
|
1bb5aedfdb | ||
|
|
0a793fb1c6 | ||
|
|
a401eeec11 | ||
|
|
d9bcc66930 | ||
|
|
01921e3454 | ||
|
|
158f6e25cf | ||
|
|
562c4b2637 | ||
|
|
51fd5d87f7 | ||
|
|
28fb56bfa1 | ||
|
|
c1052b36dc | ||
|
|
c62c9b1c78 | ||
|
|
feccbd13bd | ||
|
|
5b1e21345f | ||
|
|
33939f4096 | ||
|
|
96f5a0ab44 | ||
|
|
d9f7735c94 | ||
|
|
4aae8ab720 | ||
|
|
b83c69f002 | ||
|
|
c74e0b89f7 | ||
|
|
9ee7ff9509 | ||
|
|
74a21d6418 | ||
|
|
15f390ade7 | ||
|
|
bb4e3815d1 | ||
|
|
8fa0175b98 | ||
|
|
ee59622b98 | ||
|
|
a1452ad353 |
4
.github/workflows/release-ui.yml
vendored
4
.github/workflows/release-ui.yml
vendored
@@ -12,8 +12,8 @@ env:
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
# Automated via reusable call (main releases); manual runs allowed on dev.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
|
||||
# Automated via reusable call (main releases); manual runs allowed on dev/main.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
7
.opencode/commands/release-notes.md
Normal file
7
.opencode/commands/release-notes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Creates release notes
|
||||
agent: build
|
||||
---
|
||||
|
||||
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
|
||||
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Neural Nomads
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4655
package-lock.json
generated
4655
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/server",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@codenomad/ui-host-worker",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.8.1",
|
||||
"minServerVersion": "0.9.2",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -505,7 +505,6 @@ app.on("before-quit", async (event) => {
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
// CodeNomad supports a single window; closing it should quit the app on all platforms.
|
||||
app.quit()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
@@ -109,11 +111,13 @@ export class CliProcessManager extends EventEmitter {
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
@@ -175,10 +179,90 @@ 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(() => {
|
||||
child.kill("SIGKILL")
|
||||
}, 4000)
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
@@ -188,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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,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" })
|
||||
@@ -376,4 +477,3 @@ export class CliProcessManager extends EventEmitter {
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "@codenomad/opencode-config",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.30"
|
||||
"@opencode-ai/plugin": "1.1.42"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,29 @@ You can configure the server using flags or environment variables:
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||
|
||||
### Authentication
|
||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||
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.0",
|
||||
"version": "0.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
|
||||
@@ -15,15 +15,25 @@ export interface AuthManagerInit {
|
||||
username: string
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
this.authStore = null
|
||||
this.tokenManager = null
|
||||
return
|
||||
}
|
||||
|
||||
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
||||
|
||||
@@ -37,6 +47,10 @@ export class AuthManager {
|
||||
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||
}
|
||||
|
||||
isAuthEnabled(): boolean {
|
||||
return this.authEnabled
|
||||
}
|
||||
|
||||
getCookieName(): string {
|
||||
return this.cookieName
|
||||
}
|
||||
@@ -56,19 +70,31 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
validateLogin(username: string, password: string): boolean {
|
||||
return this.authStore.validateCredentials(username, password)
|
||||
if (!this.authEnabled) {
|
||||
return true
|
||||
}
|
||||
return this.requireAuthStore().validateCredentials(username, password)
|
||||
}
|
||||
|
||||
createSession(username: string) {
|
||||
if (!this.authEnabled) {
|
||||
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
|
||||
}
|
||||
return this.sessionManager.createSession(username)
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.authStore.getStatus()
|
||||
if (!this.authEnabled) {
|
||||
return { username: this.init.username, passwordUserProvided: false }
|
||||
}
|
||||
return this.requireAuthStore().getStatus()
|
||||
}
|
||||
|
||||
setPassword(password: string) {
|
||||
return this.authStore.setPassword({ password, markUserProvided: true })
|
||||
if (!this.authEnabled) {
|
||||
throw new Error("Internal authentication is disabled")
|
||||
}
|
||||
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
|
||||
}
|
||||
|
||||
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||
@@ -76,6 +102,12 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||
if (!this.authEnabled) {
|
||||
// When auth is disabled, treat all requests as authenticated.
|
||||
// We still return a stable username so callers can display it.
|
||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers.cookie)
|
||||
const sessionId = cookies[this.cookieName]
|
||||
const session = this.sessionManager.getSession(sessionId)
|
||||
@@ -90,6 +122,13 @@ export class AuthManager {
|
||||
clearSessionCookie(reply: FastifyReply) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||
}
|
||||
|
||||
private requireAuthStore(): AuthStore {
|
||||
if (!this.authStore) {
|
||||
throw new Error("Auth store is unavailable")
|
||||
}
|
||||
return this.authStore
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
|
||||
@@ -13,8 +13,11 @@ const PreferencesSchema = z.object({
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
modelFavorites: z.array(ModelPreferenceSchema).default([]),
|
||||
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
|
||||
@@ -222,20 +222,18 @@ export class FileSystemBrowser {
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
if (!options.includeFiles && !entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
|
||||
// are treated as directories in directory-only listings.
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = entry.isDirectory()
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ interface CliOptions {
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
@@ -84,6 +85,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
.default(false),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--dangerously-skip-auth",
|
||||
"Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).",
|
||||
)
|
||||
.env("CODENOMAD_SKIP_AUTH")
|
||||
.default(false),
|
||||
)
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
@@ -104,8 +113,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
username: string
|
||||
password?: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
|
||||
const parseBooleanEnv = (value: string | undefined): boolean => {
|
||||
const normalized = (value ?? "").trim().toLowerCase()
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on"
|
||||
}
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
@@ -130,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +190,12 @@ async function main() {
|
||||
|
||||
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
||||
|
||||
if (options.dangerouslySkipAuth) {
|
||||
logger.warn(
|
||||
"DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).",
|
||||
)
|
||||
}
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
@@ -195,11 +217,12 @@ async function main() {
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
logger.child({ component: "auth" }),
|
||||
)
|
||||
|
||||
if (options.generateToken) {
|
||||
if (options.generateToken && !options.dangerouslySkipAuth) {
|
||||
const token = authManager.issueBootstrapToken()
|
||||
if (token) {
|
||||
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||
@@ -286,21 +309,33 @@ async function main() {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info("Received shutdown signal, closing server")
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
logger.info("Received shutdown signal, stopping workspaces and server")
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
const shutdownWorkspaces = (async () => {
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
})()
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
})()
|
||||
|
||||
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
|
||||
@@ -380,6 +380,16 @@ async function proxyWorkspaceRequest(args: {
|
||||
if (instanceAuthHeader) {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
|
||||
// Enforce per-workspace directory scoping for all proxied OpenCode requests.
|
||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||
const directory = workspace.path
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||
|
||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
||||
|
||||
return headers
|
||||
},
|
||||
onError: (proxyReply, { error }) => {
|
||||
|
||||
@@ -187,16 +187,27 @@ export class WorkspaceManager {
|
||||
|
||||
async shutdown() {
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
|
||||
const stopTasks: Array<Promise<void>> = []
|
||||
|
||||
for (const [id, workspace] of this.workspaces) {
|
||||
if (workspace.pid) {
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
})
|
||||
} else {
|
||||
if (!workspace.pid) {
|
||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||
continue
|
||||
}
|
||||
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
stopTasks.push(
|
||||
this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (stopTasks.length > 0) {
|
||||
await Promise.allSettled(stopTasks)
|
||||
}
|
||||
|
||||
this.workspaces.clear()
|
||||
this.opencodeAuth.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"identifier": "main-window-native-dialogs",
|
||||
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -34,6 +34,8 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -276,6 +278,7 @@ impl CliProcessManager {
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
@@ -290,7 +293,12 @@ impl CliProcessManager {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(4) {
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
log_line(&format!(
|
||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||
CLI_STOP_GRACE_SECS,
|
||||
child.id()
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
@@ -456,13 +464,33 @@ impl CliProcessManager {
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
thread::spawn(move || {
|
||||
let code = {
|
||||
let mut guard = child_holder.lock();
|
||||
if let Some(child) = guard.as_mut() {
|
||||
child.wait().ok()
|
||||
} else {
|
||||
None
|
||||
// Do not hold the child mutex while waiting for process exit.
|
||||
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
|
||||
// same lock to send SIGTERM/SIGKILL when the user quits the app.
|
||||
let code = loop {
|
||||
let maybe_exited = {
|
||||
let mut guard = child_holder.lock();
|
||||
if guard.is_none() {
|
||||
return;
|
||||
}
|
||||
match guard
|
||||
.as_mut()
|
||||
.and_then(|child| child.try_wait().ok().flatten())
|
||||
{
|
||||
Some(status) => {
|
||||
// Drop the handle after the process exits so other callers
|
||||
// don't attempt to stop/kill a finished process.
|
||||
*guard = None;
|
||||
Some(status)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(status) = maybe_exited {
|
||||
break Some(status);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
|
||||
let mut locked = status_clone.lock();
|
||||
|
||||
@@ -4,6 +4,7 @@ mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
@@ -11,6 +12,8 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
@@ -39,7 +42,10 @@ fn is_dev_mode() -> bool {
|
||||
fn should_allow_internal(url: &Url) -> bool {
|
||||
match url.scheme() {
|
||||
"tauri" | "asset" | "file" => true,
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
|
||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||
// This must be treated as an internal origin or the navigation guard will
|
||||
// redirect it to the system browser and the app will appear blank.
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -163,7 +169,13 @@ fn main() {
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
tauri::RunEvent::ExitRequested { api, .. } => {
|
||||
// `app_handle.exit(0)` triggers another `ExitRequested`. Without a guard, we can
|
||||
// prevent exit forever and the app never quits (Cmd+Q / Quit menu appears stuck).
|
||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
api.prevent_exit();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
@@ -173,18 +185,21 @@ fn main() {
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
api.prevent_close();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
src/renderer/public/logo.png
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
@@ -28,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
@@ -17,6 +18,7 @@ import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
@@ -50,6 +52,7 @@ const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
recordWorkspaceLaunch,
|
||||
@@ -94,6 +97,7 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void initGithubStars()
|
||||
updateInstanceTabBarHeight()
|
||||
const handleResize = () => updateInstanceTabBarHeight()
|
||||
window.addEventListener("resize", handleResize)
|
||||
@@ -117,7 +121,7 @@ const App: Component = () => {
|
||||
|
||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||
if (!error) {
|
||||
return "Failed to launch workspace"
|
||||
return t("app.launchError.fallbackMessage")
|
||||
}
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
@@ -200,12 +204,12 @@ const App: Component = () => {
|
||||
|
||||
async function handleCloseInstance(instanceId: string) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
"Stop OpenCode instance? This will stop the server.",
|
||||
t("app.stopInstance.confirmMessage"),
|
||||
{
|
||||
title: "Stop instance",
|
||||
title: t("app.stopInstance.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: "Stop",
|
||||
cancelLabel: "Keep running",
|
||||
confirmLabel: t("app.stopInstance.confirmLabel"),
|
||||
cancelLabel: t("app.stopInstance.cancelLabel"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -328,21 +332,20 @@ const App: Component = () => {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
||||
binary from Advanced Settings.
|
||||
{t("app.launchError.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -354,11 +357,11 @@ const App: Component = () => {
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
Open Advanced Settings
|
||||
{t("app.launchError.openAdvancedSettings")}
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||
Close
|
||||
{t("app.launchError.close")}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
@@ -428,7 +431,7 @@ const App: Component = () => {
|
||||
clearLaunchError()
|
||||
}}
|
||||
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Close (Esc)"
|
||||
title={t("app.launchError.closeTitle")}
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface AdvancedSettingsModalProps {
|
||||
open: boolean
|
||||
@@ -12,6 +13,8 @@ interface AdvancedSettingsModalProps {
|
||||
}
|
||||
|
||||
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
@@ -19,7 +22,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("advancedSettings.title")}</Dialog.Title>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
@@ -32,8 +35,8 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Environment Variables</h3>
|
||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
||||
<h3 class="panel-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
||||
<p class="panel-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||
@@ -47,7 +50,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Close
|
||||
{t("advancedSettings.actions.close")}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -15,6 +16,7 @@ interface AgentSelectorProps {
|
||||
}
|
||||
|
||||
export default function AgentSelector(props: AgentSelectorProps) {
|
||||
const { t } = useI18n()
|
||||
const instanceAgents = () => agents().get(props.instanceId) || []
|
||||
|
||||
const session = createMemo(() => {
|
||||
@@ -71,7 +73,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
options={availableAgents()}
|
||||
optionValue="name"
|
||||
optionTextValue="name"
|
||||
placeholder="Select agent..."
|
||||
placeholder={t("agentSelector.placeholder")}
|
||||
itemComponent={(itemProps) => (
|
||||
<Select.Item
|
||||
item={itemProps.item}
|
||||
@@ -81,7 +83,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
||||
<span>{itemProps.item.rawValue.name}</span>
|
||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
||||
<span class="neutral-badge">subagent</span>
|
||||
<span class="neutral-badge">{t("agentSelector.badge.subagent")}</span>
|
||||
</Show>
|
||||
</Select.ItemLabel>
|
||||
<Show when={itemProps.item.rawValue.description}>
|
||||
@@ -99,15 +101,17 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
data-agent-selector
|
||||
class="selector-trigger"
|
||||
>
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label">
|
||||
<span class="selector-trigger-primary">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
</div>
|
||||
<Select.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Select.Icon>
|
||||
|
||||
@@ -2,28 +2,26 @@ import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
||||
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
|
||||
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string }> = {
|
||||
info: {
|
||||
badgeBg: "var(--badge-neutral-bg)",
|
||||
badgeBorder: "var(--border-base)",
|
||||
badgeText: "var(--accent-primary)",
|
||||
symbol: "i",
|
||||
fallbackTitle: "Heads up",
|
||||
},
|
||||
warning: {
|
||||
badgeBg: "rgba(255, 152, 0, 0.14)",
|
||||
badgeBorder: "var(--status-warning)",
|
||||
badgeText: "var(--status-warning)",
|
||||
symbol: "!",
|
||||
fallbackTitle: "Please review",
|
||||
},
|
||||
error: {
|
||||
badgeBg: "var(--danger-soft-bg)",
|
||||
badgeBorder: "var(--status-error)",
|
||||
badgeText: "var(--status-error)",
|
||||
symbol: "!",
|
||||
fallbackTitle: "Something went wrong",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,6 +58,7 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
|
||||
}
|
||||
|
||||
const AlertDialog: Component = () => {
|
||||
const { t } = useI18n()
|
||||
let primaryButtonRef: HTMLButtonElement | undefined
|
||||
let promptInputRef: HTMLInputElement | undefined
|
||||
|
||||
@@ -82,11 +81,25 @@ const AlertDialog: Component = () => {
|
||||
{(payload) => {
|
||||
const variant = payload.variant ?? "info"
|
||||
const accent = variantAccent[variant]
|
||||
const title = payload.title || accent.fallbackTitle
|
||||
|
||||
const fallbackTitle =
|
||||
variant === "warning"
|
||||
? t("alertDialog.fallbackTitle.warning")
|
||||
: variant === "error"
|
||||
? t("alertDialog.fallbackTitle.error")
|
||||
: t("alertDialog.fallbackTitle.info")
|
||||
|
||||
const title = payload.title || fallbackTitle
|
||||
const isConfirm = payload.type === "confirm"
|
||||
const isPrompt = payload.type === "prompt"
|
||||
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
|
||||
const cancelLabel = payload.cancelLabel || "Cancel"
|
||||
const confirmLabel =
|
||||
payload.confirmLabel ||
|
||||
(isConfirm
|
||||
? t("alertDialog.actions.confirm")
|
||||
: isPrompt
|
||||
? t("alertDialog.actions.run")
|
||||
: t("alertDialog.actions.ok"))
|
||||
const cancelLabel = payload.cancelLabel || t("alertDialog.actions.cancel")
|
||||
|
||||
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
|
||||
|
||||
@@ -127,7 +140,9 @@ const AlertDialog: Component = () => {
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
|
||||
<label class="text-sm font-medium text-secondary">
|
||||
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||
</label>
|
||||
<input
|
||||
ref={(el) => {
|
||||
promptInputRef = el
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component } from "solid-js"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface AttachmentChipProps {
|
||||
attachment: Attachment
|
||||
@@ -7,6 +8,7 @@ interface AttachmentChipProps {
|
||||
}
|
||||
|
||||
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<div
|
||||
class="attachment-chip"
|
||||
@@ -16,7 +18,7 @@ const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
||||
<button
|
||||
onClick={props.onRemove}
|
||||
class="attachment-remove"
|
||||
aria-label="Remove attachment"
|
||||
aria-label={t("attachmentChip.removeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
||||
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface BackgroundProcessOutputDialogProps {
|
||||
open: boolean
|
||||
@@ -12,6 +13,7 @@ interface BackgroundProcessOutputDialogProps {
|
||||
}
|
||||
|
||||
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const [output, setOutput] = createSignal("")
|
||||
const [outputHtml, setOutputHtml] = createSignal("")
|
||||
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
||||
@@ -67,7 +69,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setRawOutput("Failed to load output.")
|
||||
setRawOutput(t("backgroundProcessOutputDialog.loadErrorFallback"))
|
||||
setAnsiEnabled(false)
|
||||
setOutputHtml("")
|
||||
})
|
||||
@@ -121,7 +123,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{t("backgroundProcessOutputDialog.title")}</Dialog.Title>
|
||||
<Show when={props.process}>
|
||||
<span class="text-xs text-secondary block">
|
||||
{props.process?.title} · {props.process?.id}
|
||||
@@ -133,16 +135,16 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
||||
</div>
|
||||
|
||||
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
|
||||
Close
|
||||
{t("backgroundProcessOutputDialog.actions.close")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<Show when={loading()}>
|
||||
<p class="text-xs text-secondary">Loading output...</p>
|
||||
<p class="text-xs text-secondary">{t("backgroundProcessOutputDialog.loading")}</p>
|
||||
</Show>
|
||||
<Show when={!loading()}>
|
||||
<Show when={truncated()}>
|
||||
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
|
||||
<p class="text-xs text-secondary mb-2">{t("backgroundProcessOutputDialog.truncatedNotice")}</p>
|
||||
</Show>
|
||||
<Show
|
||||
when={ansiEnabled()}
|
||||
|
||||
38
packages/ui/src/components/brand-icons.tsx
Normal file
38
packages/ui/src/components/brand-icons.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
type BrandIconProps = {
|
||||
class?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 98 96"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 64 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const inlineLoadedLanguages = new Set<string>()
|
||||
|
||||
@@ -15,6 +16,7 @@ interface CodeBlockInlineProps {
|
||||
}
|
||||
|
||||
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
const { t } = useI18n()
|
||||
const { isDark } = useTheme()
|
||||
const [html, setHtml] = createSignal("")
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -53,7 +55,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
|
||||
const highlighted = highlighter.codeToHtml(props.code, {
|
||||
lang: props.language as CodeToHtmlOptions["lang"],
|
||||
theme: isDark() ? "github-dark" : "github-light",
|
||||
theme: isDark() ? "github-dark" : "github-light-high-contrast",
|
||||
})
|
||||
setHtml(highlighted)
|
||||
} catch {
|
||||
@@ -97,8 +99,8 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
<Show when={copied()} fallback={t("codeBlockInline.actions.copy")}>
|
||||
{t("codeBlockInline.actions.copied")}
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import type { Command } from "../lib/commands"
|
||||
import { resolveResolvable, type Command } from "../lib/commands"
|
||||
import Kbd from "./kbd"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
@@ -24,6 +25,7 @@ function buildShortcutString(shortcut: Command["shortcut"]): string {
|
||||
}
|
||||
|
||||
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
||||
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
|
||||
@@ -32,6 +34,27 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
|
||||
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
||||
|
||||
const categoryLabel = (category: string) => {
|
||||
switch (category) {
|
||||
case "Custom Commands":
|
||||
return t("commandPalette.category.customCommands")
|
||||
case "Instance":
|
||||
return t("commandPalette.category.instance")
|
||||
case "Session":
|
||||
return t("commandPalette.category.session")
|
||||
case "Agent & Model":
|
||||
return t("commandPalette.category.agentModel")
|
||||
case "Input & Focus":
|
||||
return t("commandPalette.category.inputFocus")
|
||||
case "System":
|
||||
return t("commandPalette.category.system")
|
||||
case "Other":
|
||||
return t("commandPalette.category.other")
|
||||
default:
|
||||
return category
|
||||
}
|
||||
}
|
||||
|
||||
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
||||
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
||||
|
||||
@@ -41,18 +64,21 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
|
||||
const filtered = q
|
||||
? source.filter((cmd) => {
|
||||
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
||||
const label = resolveResolvable(cmd.label)
|
||||
const description = resolveResolvable(cmd.description)
|
||||
const keywords = cmd.keywords ? resolveResolvable(cmd.keywords) : undefined
|
||||
const category = cmd.category ? resolveResolvable(cmd.category) : undefined
|
||||
const labelMatch = label.toLowerCase().includes(q)
|
||||
const descMatch = cmd.description.toLowerCase().includes(q)
|
||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
||||
const categoryMatch = cmd.category?.toLowerCase().includes(q)
|
||||
const descMatch = description.toLowerCase().includes(q)
|
||||
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(q))
|
||||
const categoryMatch = category?.toLowerCase().includes(q)
|
||||
return labelMatch || descMatch || keywordMatch || categoryMatch
|
||||
})
|
||||
: source
|
||||
|
||||
const groupsMap = new Map<string, Command[]>()
|
||||
for (const cmd of filtered) {
|
||||
const category = cmd.category || "Other"
|
||||
const category = (cmd.category ? resolveResolvable(cmd.category) : undefined) || "Other"
|
||||
const list = groupsMap.get(category)
|
||||
if (list) {
|
||||
list.push(cmd)
|
||||
@@ -189,12 +215,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||
<Dialog.Content
|
||||
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
|
||||
<Dialog.Content
|
||||
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Dialog.Title class="sr-only">{t("commandPalette.title")}</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">{t("commandPalette.description")}</Dialog.Description>
|
||||
|
||||
<div class="modal-search-container">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -214,7 +240,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
setQuery(e.currentTarget.value)
|
||||
setSelectedCommandId(null)
|
||||
}}
|
||||
placeholder="Type a command or search..."
|
||||
placeholder={t("commandPalette.searchPlaceholder")}
|
||||
class="modal-search-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -228,13 +254,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
>
|
||||
<Show
|
||||
when={orderedCommands().length > 0}
|
||||
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
|
||||
fallback={<div class="modal-empty-state">{t("commandPalette.empty", { query: query() })}</div>}
|
||||
>
|
||||
<For each={groupedCommandList()}>
|
||||
{(group) => (
|
||||
<div class="py-2">
|
||||
<div class="modal-section-header">
|
||||
{group.category}
|
||||
{categoryLabel(group.category)}
|
||||
</div>
|
||||
<For each={group.commands}>
|
||||
{(command, localIndex) => {
|
||||
@@ -257,10 +283,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="modal-item-label">
|
||||
{typeof command.label === "function" ? command.label() : command.label}
|
||||
{resolveResolvable(command.label)}
|
||||
</div>
|
||||
<div class="modal-item-description">
|
||||
{command.description}
|
||||
{resolveResolvable(command.description)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={command.shortcut}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server
|
||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
function normalizePathKey(input?: string | null) {
|
||||
if (!input || input === "." || input === "./") {
|
||||
@@ -62,6 +63,7 @@ type FolderRow =
|
||||
| { type: "folder"; entry: FileSystemEntry }
|
||||
|
||||
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
@@ -110,7 +112,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const metadata = await loadDirectory()
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -200,7 +202,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const metadata = await loadDirectory(path)
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
}
|
||||
}
|
||||
@@ -266,19 +268,19 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
}
|
||||
|
||||
const name =
|
||||
(await showPromptDialog("Create a new folder in the current directory.", {
|
||||
title: "New Folder",
|
||||
inputLabel: "Folder name",
|
||||
inputPlaceholder: "e.g. my-new-project",
|
||||
confirmLabel: "Create",
|
||||
cancelLabel: "Cancel",
|
||||
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||
title: t("directoryBrowser.createFolder.title"),
|
||||
inputLabel: t("directoryBrowser.createFolder.inputLabel"),
|
||||
inputPlaceholder: t("directoryBrowser.createFolder.inputPlaceholder"),
|
||||
confirmLabel: t("directoryBrowser.createFolder.confirmLabel"),
|
||||
cancelLabel: t("directoryBrowser.createFolder.cancelLabel"),
|
||||
}))?.trim() ?? ""
|
||||
if (!name) return
|
||||
|
||||
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
|
||||
showAlertDialog("Please enter a single folder name.", {
|
||||
showAlertDialog(t("directoryBrowser.createFolder.invalidNameMessage"), {
|
||||
variant: "warning",
|
||||
detail: "Folder names cannot include slashes, '..', or '~'.",
|
||||
detail: t("directoryBrowser.createFolder.invalidNameDetail"),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -297,8 +299,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
|
||||
await navigateTo(created.path)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to create folder"
|
||||
showAlertDialog(message, { variant: "error", title: "Unable to create folder" })
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.createFolder.errorFallback")
|
||||
showAlertDialog(message, { variant: "error", title: t("directoryBrowser.createFolder.errorFallback") })
|
||||
} finally {
|
||||
setCreatingFolder(false)
|
||||
}
|
||||
@@ -323,10 +325,10 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<div class="directory-browser-heading">
|
||||
<h3 class="directory-browser-title">{props.title}</h3>
|
||||
<p class="directory-browser-description">
|
||||
{props.description || "Browse folders under the configured workspace root."}
|
||||
{props.description || t("directoryBrowser.defaultDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}>
|
||||
<button type="button" class="directory-browser-close" aria-label={t("directoryBrowser.close")} onClick={props.onClose}>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -335,7 +337,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<Show when={rootPath()}>
|
||||
<div class="directory-browser-current">
|
||||
<div class="directory-browser-current-meta">
|
||||
<span class="directory-browser-current-label">Current folder</span>
|
||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
</div>
|
||||
<div class="directory-browser-current-actions">
|
||||
@@ -350,7 +352,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select Current
|
||||
{t("directoryBrowser.selectCurrent")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -360,7 +362,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
{creatingFolder() ? "Creating…" : "New Folder"}
|
||||
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -373,7 +375,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
|
||||
<div class="directory-browser-loading">
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
<span>Loading folders…</span>
|
||||
<span>{t("directoryBrowser.loadingFolders")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -381,13 +383,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
>
|
||||
<Show
|
||||
when={folderRows().length > 0}
|
||||
fallback={<div class="panel-empty-state flex-1">No folders available.</div>}
|
||||
fallback={<div class="panel-empty-state flex-1">{t("directoryBrowser.noFolders")}</div>}
|
||||
>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
|
||||
<For each={folderRows()}>
|
||||
{(item) => {
|
||||
const isFolder = item.type === "folder"
|
||||
const label = isFolder ? item.entry.name || item.entry.path : "Up one level"
|
||||
const label = isFolder ? item.entry.name || item.entry.path : t("directoryBrowser.upOneLevel")
|
||||
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
|
||||
return (
|
||||
<div class="panel-list-item" role="option">
|
||||
@@ -414,7 +416,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
handleEntrySelect(item.entry)
|
||||
}}
|
||||
>
|
||||
Select
|
||||
{t("directoryBrowser.select")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Loader2 } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -9,15 +10,19 @@ interface EmptyStateProps {
|
||||
}
|
||||
|
||||
const EmptyState: Component<EmptyStateProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const modifier = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"
|
||||
const shortcut = `${modifier}+N`
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
|
||||
<div class="max-w-[500px] px-8 py-12 text-center">
|
||||
<div class="mb-8 flex justify-center">
|
||||
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
|
||||
<img src={codeNomadIcon} alt={t("emptyState.logoAlt")} class="h-24 w-auto" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
<h1 class="mb-3 text-3xl font-semibold text-primary">{t("emptyState.brandTitle")}</h1>
|
||||
<p class="mb-8 text-base text-secondary">{t("emptyState.tagline")}</p>
|
||||
|
||||
|
||||
<button
|
||||
@@ -28,20 +33,20 @@ const EmptyState: Component<EmptyStateProps> = (props) => {
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Selecting...
|
||||
{t("emptyState.actions.selecting")}
|
||||
</>
|
||||
) : (
|
||||
"Select Folder"
|
||||
t("emptyState.actions.selectFolder")
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p class="text-sm text-muted">
|
||||
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
|
||||
{t("emptyState.keyboardShortcut", { shortcut })}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-1 text-sm text-muted">
|
||||
<p>Examples: ~/projects/my-app</p>
|
||||
<p>You can have multiple instances of the same folder</p>
|
||||
<p>{t("emptyState.examples", { example: "~/projects/my-app" })}</p>
|
||||
<p>{t("emptyState.multipleInstances")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Component, createSignal, For, Show } from "solid-js"
|
||||
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface EnvironmentVariablesEditorProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
addEnvironmentVariable,
|
||||
@@ -54,9 +56,11 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Globe class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Environment Variables</span>
|
||||
<span class="text-sm font-medium text-secondary">{t("envEditor.title")}</span>
|
||||
<span class="text-xs text-muted">
|
||||
({entries().length} variable{entries().length !== 1 ? "s" : ""})
|
||||
{entries().length === 1
|
||||
? t("envEditor.count.one", { count: entries().length })
|
||||
: t("envEditor.count.other", { count: entries().length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -73,8 +77,8 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
||||
value={key}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
|
||||
placeholder="Variable name"
|
||||
title="Variable name (read-only)"
|
||||
placeholder={t("envEditor.fields.name.placeholder")}
|
||||
title={t("envEditor.fields.name.readOnlyTitle")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -82,14 +86,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
||||
disabled={props.disabled}
|
||||
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable value"
|
||||
placeholder={t("envEditor.fields.value.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveVariable(key)}
|
||||
disabled={props.disabled}
|
||||
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Remove variable"
|
||||
title={t("envEditor.actions.remove.title")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -110,7 +114,7 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable name"
|
||||
placeholder={t("envEditor.fields.name.placeholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -119,14 +123,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable value"
|
||||
placeholder={t("envEditor.fields.value.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddVariable}
|
||||
disabled={props.disabled || !newKey().trim()}
|
||||
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Add variable"
|
||||
title={t("envEditor.actions.add.title")}
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -134,12 +138,12 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
||||
|
||||
<Show when={entries().length === 0}>
|
||||
<div class="text-xs text-muted text-center py-2">
|
||||
No environment variables configured. Add variables above to customize the OpenCode environment.
|
||||
{t("envEditor.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="text-xs text-muted mt-2">
|
||||
These variables will be available in the OpenCode environment when starting instances.
|
||||
{t("envEditor.help")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show } from "solid-js"
|
||||
import { Maximize2, Minimize2 } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface ExpandButtonProps {
|
||||
expandState: () => "normal" | "expanded"
|
||||
@@ -7,6 +8,8 @@ interface ExpandButtonProps {
|
||||
}
|
||||
|
||||
export default function ExpandButton(props: ExpandButtonProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleClick() {
|
||||
const current = props.expandState()
|
||||
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
|
||||
@@ -17,7 +20,7 @@ export default function ExpandButton(props: ExpandButtonProps) {
|
||||
type="button"
|
||||
class="prompt-expand-button"
|
||||
onClick={handleClick}
|
||||
aria-label="Toggle chat input height"
|
||||
aria-label={t("expandButton.toggleAriaLabel")}
|
||||
>
|
||||
<Show
|
||||
when={props.expandState() === "normal"}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
@@ -49,6 +50,7 @@ interface FileSystemBrowserDialogProps {
|
||||
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
||||
|
||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
||||
@@ -135,7 +137,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
setRootPath(metadata.rootPath)
|
||||
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
const message = err instanceof Error ? err.message : t("filesystemBrowser.errors.loadFilesystemFallback")
|
||||
setError(message)
|
||||
}
|
||||
}
|
||||
@@ -143,10 +145,10 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
function describeLoadingPath() {
|
||||
const path = loadingPath()
|
||||
if (!path) {
|
||||
return "filesystem"
|
||||
return t("filesystemBrowser.loading.filesystem")
|
||||
}
|
||||
if (path === ".") {
|
||||
return rootPath() || "workspace root"
|
||||
return rootPath() || t("filesystemBrowser.loading.workspaceRoot")
|
||||
}
|
||||
return resolveAbsolutePath(rootPath(), path)
|
||||
}
|
||||
@@ -176,7 +178,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
function handleNavigateTo(path: string) {
|
||||
void fetchDirectory(path, true).catch((err) => {
|
||||
log.error("Failed to open directory", err)
|
||||
setError(err instanceof Error ? err.message : "Unable to open directory")
|
||||
setError(err instanceof Error ? err.message : t("filesystemBrowser.errors.openDirectoryFallback"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -277,19 +279,21 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<div class="panel-header flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="panel-title">{props.title}</h3>
|
||||
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
|
||||
<p class="panel-subtitle">{props.description || t("filesystemBrowser.descriptionFallback")}</p>
|
||||
<Show when={rootPath()}>
|
||||
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
||||
<p class="text-xs text-muted mt-1 font-mono break-all">
|
||||
{t("filesystemBrowser.rootLabel", { root: rootPath() })}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
<X class="w-4 h-4" />
|
||||
Close
|
||||
{t("filesystemBrowser.actions.close")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
|
||||
<label class="w-full text-sm text-secondary mb-2 block">{t("filesystemBrowser.filterLabel")}</label>
|
||||
<div class="selector-input-group">
|
||||
<div class="flex items-center gap-2 px-3 text-muted">
|
||||
<Search class="w-4 h-4" />
|
||||
@@ -301,7 +305,11 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
||||
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
|
||||
placeholder={
|
||||
props.mode === "directories"
|
||||
? t("filesystemBrowser.search.placeholder.directories")
|
||||
: t("filesystemBrowser.search.placeholder.files")
|
||||
}
|
||||
class="selector-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -311,7 +319,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<div class="px-4 pb-2">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
|
||||
<div>
|
||||
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
|
||||
<p class="text-xs text-secondary uppercase tracking-wide">{t("filesystemBrowser.currentFolder.label")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -319,7 +327,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
class="selector-button selector-button-secondary whitespace-nowrap"
|
||||
onClick={() => props.onSelect(currentAbsolutePath())}
|
||||
>
|
||||
Select Current
|
||||
{t("filesystemBrowser.currentFolder.selectCurrent")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,7 +344,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
<span>Loading {describeLoadingPath()}…</span>
|
||||
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -345,16 +353,16 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<Show when={loadingPath()}>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Loading {describeLoadingPath()}…</span>
|
||||
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={folderRows().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
||||
<p>No entries found.</p>
|
||||
<p>{t("filesystemBrowser.empty.noEntries")}</p>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||
Retry
|
||||
{t("filesystemBrowser.actions.retry")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -370,7 +378,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<ArrowUpLeft class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="directory-browser-row-text">
|
||||
<span class="directory-browser-row-name">Up one level</span>
|
||||
<span class="directory-browser-row-name">{t("filesystemBrowser.navigation.upOneLevel")}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -412,7 +420,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
selectEntry()
|
||||
}}
|
||||
>
|
||||
Select
|
||||
{t("filesystemBrowser.actions.select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,15 +436,15 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
<span>{t("filesystemBrowser.hints.navigate")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
<span>{t("filesystemBrowser.hints.select")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Close</span>
|
||||
<span>{t("filesystemBrowser.hints.close")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,4 +456,3 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
}
|
||||
|
||||
export default FileSystemBrowserDialog
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n, type Locale } from "../lib/i18n"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -20,13 +26,27 @@ interface FolderSelectionViewProps {
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences } = useConfig()
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
type LanguageOption = { value: Locale; label: string }
|
||||
|
||||
const languageOptions: LanguageOption[] = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "es", label: "Español" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "zh-Hans", label: "简体中文" },
|
||||
]
|
||||
|
||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||
|
||||
const folders = () => recentFolders()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
@@ -178,16 +198,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||
return t("time.relative.justNow")
|
||||
}
|
||||
|
||||
function handleFolderSelect(path: string) {
|
||||
if (isLoading()) return
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
@@ -195,7 +220,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
if (nativeDialogsAvailable) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: "Select Workspace",
|
||||
title: t("folderSelection.dialog.title"),
|
||||
defaultPath: fallbackPath,
|
||||
})
|
||||
if (selected) {
|
||||
@@ -242,170 +267,282 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<div class="absolute top-4 left-6">
|
||||
<Select<LanguageOption>
|
||||
value={selectedLanguageOption()}
|
||||
onChange={(value) => {
|
||||
if (!value) return
|
||||
if (value.value === locale()) return
|
||||
updatePreferences({ locale: value.value })
|
||||
}}
|
||||
options={languageOptions}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
itemComponent={(itemProps) => (
|
||||
<Select.Item item={itemProps.item} class="selector-option">
|
||||
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||
</Select.Item>
|
||||
)}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="selector-trigger"
|
||||
aria-label={t("folderSelection.language.ariaLabel")}
|
||||
title={t("folderSelection.language.ariaLabel")}
|
||||
>
|
||||
<Languages class="w-4 h-4 icon-muted" aria-hidden="true" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<LanguageOption>>
|
||||
{(state) => (
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
{state.selectedOption()?.label}
|
||||
</span>
|
||||
)}
|
||||
</Select.Value>
|
||||
</div>
|
||||
<Select.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="selector-popover min-w-[180px]">
|
||||
<Select.Listbox class="selector-listbox" />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
|
||||
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button
|
||||
onClick={() => props.onAdvancedSettingsOpen?.()}
|
||||
class="panel-section-header w-full justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label={t("folderSelection.links.github")}
|
||||
title={t("folderSelection.links.github")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label={t("folderSelection.links.githubStars")}
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
<Show when={githubStars() !== null}>
|
||||
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label={t("folderSelection.links.discord")}
|
||||
title={t("folderSelection.links.discord")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||
<p class="panel-subtitle">
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Left column: version + browse + advanced settings */}
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-body flex items-center justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>{t("folderSelection.hints.navigate")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>{t("folderSelection.hints.select")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>{t("folderSelection.hints.remove")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>{t("folderSelection.hints.browse")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,8 +551,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="folder-loading-overlay">
|
||||
<div class="folder-loading-indicator">
|
||||
<div class="spinner" />
|
||||
<p class="folder-loading-text">Starting instance…</p>
|
||||
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
|
||||
<p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
|
||||
<p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -431,8 +568,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
title="Select Workspace"
|
||||
description="Select workspace to start coding."
|
||||
title={t("folderSelection.dialog.title")}
|
||||
description={t("folderSelection.dialog.description")}
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, c
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface InfoViewProps {
|
||||
instanceId: string
|
||||
@@ -10,6 +11,7 @@ interface InfoViewProps {
|
||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
const InfoView: Component<InfoViewProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const savedState = logsScrollState.get(props.instanceId)
|
||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||
@@ -90,18 +92,18 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
||||
|
||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="log-header">
|
||||
<h2 class="panel-title">Server Logs</h2>
|
||||
<h2 class="panel-title">{t("infoView.logs.title")}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
{t("infoView.logs.actions.show")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||
Hide server logs
|
||||
{t("infoView.logs.actions.hide")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -116,17 +118,17 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<div class="log-paused-state">
|
||||
<p class="log-paused-title">Server logs are paused</p>
|
||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
||||
<p class="log-paused-title">{t("infoView.logs.paused.title")}</p>
|
||||
<p class="log-paused-description">{t("infoView.logs.paused.description")}</p>
|
||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
{t("infoView.logs.actions.show")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={logs().length > 0}
|
||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
||||
fallback={<div class="log-empty-state">{t("infoView.logs.empty.waiting")}</div>}
|
||||
>
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
@@ -148,7 +150,7 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
||||
class="scroll-to-bottom"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
Scroll to bottom
|
||||
{t("infoView.logs.scrollToBottom")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface InstanceDisconnectedModalProps {
|
||||
open: boolean
|
||||
@@ -8,8 +9,10 @@ interface InstanceDisconnectedModalProps {
|
||||
}
|
||||
|
||||
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
|
||||
const folderLabel = props.folder || "this workspace"
|
||||
const reasonLabel = props.reason || "The server stopped responding"
|
||||
const { t } = useI18n()
|
||||
|
||||
const folderLabel = () => props.folder || t("instanceDisconnected.folderFallback")
|
||||
const reasonLabel = () => props.reason || t("instanceDisconnected.reasonFallback")
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} modal>
|
||||
@@ -18,25 +21,25 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("instanceDisconnected.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{folderLabel} can no longer be reached. Close the tab to continue working.
|
||||
{t("instanceDisconnected.description", { folder: folderLabel() })}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
|
||||
<p class="font-medium text-primary">Details</p>
|
||||
<p class="mt-2 text-secondary">{reasonLabel}</p>
|
||||
<p class="font-medium text-primary">{t("instanceDisconnected.details.title")}</p>
|
||||
<p class="mt-2 text-secondary">{reasonLabel()}</p>
|
||||
{props.folder && (
|
||||
<p class="mt-2 text-secondary">
|
||||
Folder: <span class="font-mono text-primary break-all">{props.folder}</span>
|
||||
{t("instanceDisconnected.details.folderLabel")} <span class="font-mono text-primary break-all">{props.folder}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
|
||||
Close Instance
|
||||
{t("instanceDisconnected.actions.closeInstance")}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, For, Show, createMemo } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
@@ -9,6 +10,7 @@ interface InstanceInfoProps {
|
||||
}
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
@@ -26,11 +28,11 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
return (
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Instance Information</h2>
|
||||
<h2 class="panel-title">{t("instanceInfo.title")}</h2>
|
||||
</div>
|
||||
<div class="panel-body space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
@@ -41,7 +43,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Project
|
||||
{t("instanceInfo.labels.project")}
|
||||
</div>
|
||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
{project().id}
|
||||
@@ -51,7 +53,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<Show when={project().vcs}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Version Control
|
||||
{t("instanceInfo.labels.versionControl")}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-primary">
|
||||
<svg
|
||||
@@ -73,7 +75,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<Show when={binaryVersion()}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
OpenCode Version
|
||||
{t("instanceInfo.labels.opencodeVersion")}
|
||||
</div>
|
||||
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
v{binaryVersion()}
|
||||
@@ -84,7 +86,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<Show when={currentInstance().binaryPath}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Binary Path
|
||||
{t("instanceInfo.labels.binaryPath")}
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{currentInstance().binaryPath}
|
||||
@@ -95,7 +97,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<Show when={environmentEntries().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
Environment Variables ({environmentEntries().length})
|
||||
{t("instanceInfo.labels.environmentVariables", { count: environmentEntries().length })}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={environmentEntries()}>
|
||||
@@ -127,24 +129,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Loading...
|
||||
{t("instanceInfo.loading")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">Server</div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">{t("instanceInfo.server.title")}</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Port:</span>
|
||||
<span class="text-secondary">{t("instanceInfo.server.port")}</span>
|
||||
<span class="text-primary font-mono">{currentInstance().port}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">PID:</span>
|
||||
<span class="text-secondary">{t("instanceInfo.server.pid")}</span>
|
||||
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Status:</span>
|
||||
<span class="text-secondary">{t("instanceInfo.server.status")}</span>
|
||||
<span class={`status-badge ${currentInstance().status}`}>
|
||||
<div
|
||||
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import Switch from "@suid/material/Switch"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
@@ -42,6 +43,7 @@ function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
|
||||
}
|
||||
|
||||
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const instance = metadataContext?.instance ?? (() => {
|
||||
if (props.initialInstance) {
|
||||
@@ -112,12 +114,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
LSP Servers
|
||||
{t("instanceServiceStatus.sections.lsp")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isLspLoading() && lspServers().length > 0}
|
||||
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
|
||||
fallback={renderEmptyState(isLspLoading() ? t("instanceServiceStatus.lsp.loading") : t("instanceServiceStatus.lsp.empty"))}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={lspServers()}>
|
||||
@@ -132,7 +134,11 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||
<span>
|
||||
{server.status === "connected"
|
||||
? t("instanceServiceStatus.lsp.status.connected")
|
||||
: t("instanceServiceStatus.lsp.status.error")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,12 +153,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
MCP Servers
|
||||
{t("instanceServiceStatus.sections.mcp")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isMcpLoading() && mcpServers().length > 0}
|
||||
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
|
||||
fallback={renderEmptyState(isMcpLoading() ? t("instanceServiceStatus.mcp.loading") : t("instanceServiceStatus.mcp.empty"))}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={mcpServers()}>
|
||||
@@ -192,7 +198,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
||||
disabled={switchDisabled()}
|
||||
color="success"
|
||||
size="small"
|
||||
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
|
||||
inputProps={{ "aria-label": t("instanceServiceStatus.mcp.toggleAriaLabel", { name: server.name }) }}
|
||||
onChange={(_, checked) => {
|
||||
if (switchDisabled()) return
|
||||
void toggleMcpServer(server.name, Boolean(checked))
|
||||
@@ -222,12 +228,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
Plugins
|
||||
{t("instanceServiceStatus.sections.plugins")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isPluginsLoading() && plugins().length > 0}
|
||||
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")}
|
||||
fallback={renderEmptyState(isPluginsLoading() ? t("instanceServiceStatus.plugins.loading") : t("instanceServiceStatus.plugins.empty"))}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={plugins()}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, createMemo } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
|
||||
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
@@ -27,6 +28,7 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
|
||||
}
|
||||
|
||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
|
||||
const statusClassName = createMemo(() => {
|
||||
const status = aggregatedStatus()
|
||||
@@ -35,13 +37,13 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
const statusTitle = createMemo(() => {
|
||||
switch (aggregatedStatus()) {
|
||||
case "permission":
|
||||
return "Waiting on permission"
|
||||
return t("instanceTab.status.permission")
|
||||
case "compacting":
|
||||
return "Compacting"
|
||||
return t("instanceTab.status.compacting")
|
||||
case "working":
|
||||
return "Working"
|
||||
return t("instanceTab.status.working")
|
||||
default:
|
||||
return "Idle"
|
||||
return t("instanceTab.status.idle")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -61,7 +63,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
<span
|
||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||
title={statusTitle()}
|
||||
aria-label={`Instance status: ${statusTitle()}`}
|
||||
aria-label={t("instanceTab.status.ariaLabel", { status: statusTitle() })}
|
||||
>
|
||||
{aggregatedStatus() === "permission" ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
@@ -77,7 +79,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close instance"
|
||||
aria-label={t("instanceTab.actions.close.ariaLabel")}
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,8 @@ import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -15,6 +17,7 @@ interface InstanceTabsProps {
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<div class="tab-bar tab-bar-instance">
|
||||
<div class="tab-container" role="tablist">
|
||||
@@ -34,8 +37,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={props.onNew}
|
||||
title="New instance (Cmd/Ctrl+N)"
|
||||
aria-label="New instance"
|
||||
title={t("instanceTabs.new.title")}
|
||||
aria-label={t("instanceTabs.new.ariaLabel")}
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -50,12 +53,13 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title="Remote connect"
|
||||
aria-label="Remote connect"
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -19,6 +20,7 @@ interface InstanceWelcomeViewProps {
|
||||
}
|
||||
|
||||
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [isCreating, setIsCreating] = createSignal(false)
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||
@@ -47,7 +49,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
ctrl: !isMac(),
|
||||
},
|
||||
handler: () => {},
|
||||
description: "New Session",
|
||||
description: t("instanceWelcome.shortcuts.newSession"),
|
||||
context: "global",
|
||||
}
|
||||
})
|
||||
@@ -248,10 +250,10 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||
return t("time.relative.justNow")
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
@@ -291,7 +293,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
setRenameTarget(null)
|
||||
} catch (error) {
|
||||
log.error("Failed to rename session:", error)
|
||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||
showToastNotification({ message: t("instanceWelcome.toasts.renameError"), variant: "error" })
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
@@ -333,11 +335,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
||||
<p class="panel-empty-state-title">{t("instanceWelcome.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("instanceWelcome.empty.description")}</p>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
||||
View Instance Info
|
||||
{t("instanceWelcome.actions.viewInstanceInfo")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -347,8 +349,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<div class="panel-empty-state-icon">
|
||||
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">Loading Sessions</p>
|
||||
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
|
||||
<p class="panel-empty-state-title">{t("instanceWelcome.loading.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("instanceWelcome.loading.description")}</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
@@ -357,9 +359,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<div class="panel-header">
|
||||
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
||||
<div>
|
||||
<h2 class="panel-title">Resume Session</h2>
|
||||
<h2 class="panel-title">{t("instanceWelcome.resume.title")}</h2>
|
||||
<p class="panel-subtitle">
|
||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||
{parentSessions().length === 1
|
||||
? t("instanceWelcome.resume.subtitle.one", { count: parentSessions().length })
|
||||
: t("instanceWelcome.resume.subtitle.other", { count: parentSessions().length })}
|
||||
</p>
|
||||
</div>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
@@ -368,7 +372,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
class="button-tertiary lg:hidden flex-shrink-0"
|
||||
onClick={openInstanceInfoOverlay}
|
||||
>
|
||||
View Instance Info
|
||||
{t("instanceWelcome.actions.viewInstanceInfo")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -404,7 +408,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled Session"}
|
||||
{session.title || t("instanceWelcome.session.untitled")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||
@@ -421,7 +425,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Rename session"
|
||||
title={t("instanceWelcome.actions.renameTitle")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -433,7 +437,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Delete session"
|
||||
title={t("instanceWelcome.actions.deleteTitle")}
|
||||
disabled={isSessionDeleting(session.id)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -470,8 +474,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
|
||||
<div class="panel flex-shrink-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Start New Session</h2>
|
||||
<p class="panel-subtitle">We’ll reuse your last agent/model automatically</p>
|
||||
<h2 class="panel-title">{t("instanceWelcome.new.title")}</h2>
|
||||
<p class="panel-subtitle">{t("instanceWelcome.new.subtitle")}</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="space-y-3">
|
||||
@@ -496,7 +500,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Create Session</span>
|
||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||
</div>
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||
</button>
|
||||
@@ -524,7 +528,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
||||
Close
|
||||
{t("instanceWelcome.overlay.close")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
||||
@@ -541,25 +545,25 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
<span>{t("instanceWelcome.hints.navigate")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">PgUp</kbd>
|
||||
<kbd class="kbd">PgDn</kbd>
|
||||
<span>Jump</span>
|
||||
<span>{t("instanceWelcome.hints.jump")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Home</kbd>
|
||||
<kbd class="kbd">End</kbd>
|
||||
<span>First/Last</span>
|
||||
<span>{t("instanceWelcome.hints.firstLast")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Resume</span>
|
||||
<span>{t("instanceWelcome.hints.resume")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Delete</span>
|
||||
<span>{t("instanceWelcome.hints.delete")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Accordion } from "@kobalte/core"
|
||||
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
import AppBar from "@suid/material/AppBar"
|
||||
import Box from "@suid/material/Box"
|
||||
import Divider from "@suid/material/Divider"
|
||||
import Drawer from "@suid/material/Drawer"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import Toolbar from "@suid/material/Toolbar"
|
||||
@@ -48,15 +47,16 @@ import { clearSessionRenderCache } from "../message-block"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import Kbd from "../kbd"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import InstanceServiceStatus from "../instance-service-status"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import ThinkingSelector from "../thinking-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import PermissionNotificationBanner from "../permission-notification-banner"
|
||||
import PermissionApprovalModal from "../permission-approval-modal"
|
||||
import Kbd from "../kbd"
|
||||
import { TodoListView } from "../tool-call/renderers/todo"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
@@ -66,6 +66,7 @@ import { getLogger } from "../../lib/logger"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import {
|
||||
SESSION_SIDEBAR_EVENT,
|
||||
type SessionSidebarRequestAction,
|
||||
@@ -86,9 +87,9 @@ interface InstanceShellProps {
|
||||
tabBarOffset: number
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
|
||||
const MIN_SESSION_SIDEBAR_WIDTH = 220
|
||||
const MAX_SESSION_SIDEBAR_WIDTH = 360
|
||||
const MAX_SESSION_SIDEBAR_WIDTH = 400
|
||||
const RIGHT_DRAWER_WIDTH = 260
|
||||
const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||
const MAX_RIGHT_DRAWER_WIDTH = 380
|
||||
@@ -120,6 +121,8 @@ function persistPinState(side: "left" | "right", value: boolean) {
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
|
||||
const [leftPinned, setLeftPinned] = createSignal(true)
|
||||
@@ -356,6 +359,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return "disconnected"
|
||||
}
|
||||
|
||||
const connectionStatusLabel = () => {
|
||||
const status = connectionStatus()
|
||||
if (status === "connected") return t("instanceShell.connection.connected")
|
||||
if (status === "connecting") return t("instanceShell.connection.connecting")
|
||||
if (status === "error" || status === "disconnected") return t("instanceShell.connection.disconnected")
|
||||
return t("instanceShell.connection.unknown")
|
||||
}
|
||||
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instance.id)
|
||||
}
|
||||
@@ -432,6 +443,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const focusVariantSelectorControl = () => {
|
||||
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||
if (!input) return false
|
||||
input.focus()
|
||||
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingSidebarAction()
|
||||
if (!pending) return
|
||||
@@ -444,7 +463,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
setPendingSidebarAction(null)
|
||||
return
|
||||
}
|
||||
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl()
|
||||
const handled =
|
||||
action === "focus-agent-selector"
|
||||
? focusAgentSelectorControl()
|
||||
: action === "focus-model-selector"
|
||||
? focusModelSelectorControl()
|
||||
: focusVariantSelectorControl()
|
||||
if (handled) {
|
||||
setPendingSidebarAction(null)
|
||||
}
|
||||
@@ -702,16 +726,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
const leftAppBarButtonLabel = () => {
|
||||
const state = leftDrawerState()
|
||||
if (state === "pinned") return "Left drawer pinned"
|
||||
if (state === "floating-closed") return "Open left drawer"
|
||||
return "Close left drawer"
|
||||
if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned")
|
||||
if (state === "floating-closed") return t("instanceShell.leftDrawer.toggle.open")
|
||||
return t("instanceShell.leftDrawer.toggle.close")
|
||||
}
|
||||
|
||||
const rightAppBarButtonLabel = () => {
|
||||
const state = rightDrawerState()
|
||||
if (state === "pinned") return "Right drawer pinned"
|
||||
if (state === "floating-closed") return "Open right drawer"
|
||||
return "Close right drawer"
|
||||
if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned")
|
||||
if (state === "floating-closed") return t("instanceShell.rightDrawer.toggle.open")
|
||||
return t("instanceShell.rightDrawer.toggle.close")
|
||||
}
|
||||
|
||||
const leftAppBarButtonIcon = () => {
|
||||
@@ -841,19 +865,21 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
|
||||
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label="Instance Info"
|
||||
title="Instance Info"
|
||||
aria-label={t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => handleSessionSelect("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
@@ -862,7 +888,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"}
|
||||
aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
||||
>
|
||||
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
@@ -884,11 +910,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
@@ -901,21 +928,20 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
|
||||
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
|
||||
|
||||
<div class="session-sidebar-selector-hints" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
<Kbd shortcut="cmd+shift+t" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -928,19 +954,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const renderPlanSectionContent = () => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
return <p class="text-xs text-secondary">Select a session to view plan.</p>
|
||||
return <p class="text-xs text-secondary">{t("instanceShell.plan.noSessionSelected")}</p>
|
||||
}
|
||||
const todoState = latestTodoState()
|
||||
if (!todoState) {
|
||||
return <p class="text-xs text-secondary">Nothing planned yet.</p>
|
||||
return <p class="text-xs text-secondary">{t("instanceShell.plan.empty")}</p>
|
||||
}
|
||||
return <TodoListView state={todoState} emptyLabel="Nothing planned yet." showStatusLabel={false} />
|
||||
return <TodoListView state={todoState} emptyLabel={t("instanceShell.plan.empty")} showStatusLabel={false} />
|
||||
}
|
||||
|
||||
const renderBackgroundProcesses = () => {
|
||||
const processes = backgroundProcessList()
|
||||
if (processes.length === 0) {
|
||||
return <p class="text-xs text-secondary">No background processes.</p>
|
||||
return <p class="text-xs text-secondary">{t("instanceShell.backgroundProcesses.empty")}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -951,9 +977,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-primary">{process.title}</span>
|
||||
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
|
||||
<span>Status: {process.status}</span>
|
||||
<span>{t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||
<span>Output: {Math.round((process.outputSizeBytes ?? 0) / 1024)}KB</span>
|
||||
<span>
|
||||
{t("instanceShell.backgroundProcesses.output", {
|
||||
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
|
||||
})}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -962,8 +992,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => openBackgroundOutput(process)}
|
||||
aria-label="Output"
|
||||
title="Output"
|
||||
aria-label={t("instanceShell.backgroundProcesses.actions.output")}
|
||||
title={t("instanceShell.backgroundProcesses.actions.output")}
|
||||
>
|
||||
<TerminalSquare class="h-4 w-4" />
|
||||
</button>
|
||||
@@ -972,8 +1002,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
disabled={process.status !== "running"}
|
||||
onClick={() => stopBackgroundProcess(process.id)}
|
||||
aria-label="Stop"
|
||||
title="Stop"
|
||||
aria-label={t("instanceShell.backgroundProcesses.actions.stop")}
|
||||
title={t("instanceShell.backgroundProcesses.actions.stop")}
|
||||
>
|
||||
<XOctagon class="h-4 w-4" />
|
||||
</button>
|
||||
@@ -981,8 +1011,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => terminateBackgroundProcess(process.id)}
|
||||
aria-label="Terminate"
|
||||
title="Terminate"
|
||||
aria-label={t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||
title={t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
@@ -997,17 +1027,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const sections = [
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||
render: renderPlanSectionContent,
|
||||
},
|
||||
{
|
||||
id: "background-processes",
|
||||
label: "Background Shells",
|
||||
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||
render: renderBackgroundProcesses,
|
||||
},
|
||||
{
|
||||
id: "mcp",
|
||||
label: "MCP Servers",
|
||||
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
@@ -1019,7 +1049,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
},
|
||||
{
|
||||
id: "lsp",
|
||||
label: "LSP Servers",
|
||||
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
@@ -1031,7 +1061,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
label: "Plugins",
|
||||
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
@@ -1057,16 +1087,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
|
||||
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
|
||||
Status Panel
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base text-primary">
|
||||
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold text-primary">
|
||||
{t("instanceShell.rightPanel.title")}
|
||||
</Typography>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={rightPinned() ? "Unpin right drawer" : "Pin right drawer"}
|
||||
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
|
||||
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
||||
>
|
||||
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
@@ -1090,7 +1120,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide">
|
||||
<span>{section.label}</span>
|
||||
<span>{t(section.labelKey)}</span>
|
||||
<ChevronDown
|
||||
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
|
||||
/>
|
||||
@@ -1267,17 +1297,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label="Open command palette"
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
Command Palette
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
<span
|
||||
class={`status-indicator ${connectionStatusClass()}`}
|
||||
aria-label={`Connection ${connectionStatus()}`}
|
||||
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
</span>
|
||||
@@ -1300,11 +1330,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1326,11 +1360,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
<Show when={!showingInfoView()}>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -1346,10 +1384,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label="Open command palette"
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
Command Palette
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
@@ -1364,19 +1402,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connected</span>
|
||||
<span class="status-text">{t("instanceShell.connection.connected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connecting...</span>
|
||||
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Disconnected</span>
|
||||
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1412,8 +1450,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-2">No session selected</p>
|
||||
<p class="text-sm">Select a session to view messages</p>
|
||||
<p class="mb-2">{t("instanceShell.empty.title")}</p>
|
||||
<p class="text-sm">{t("instanceShell.empty.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface LogsViewProps {
|
||||
instanceId: string
|
||||
@@ -9,6 +10,7 @@ interface LogsViewProps {
|
||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
const LogsView: Component<LogsViewProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const savedState = logsScrollState.get(props.instanceId)
|
||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||
@@ -83,18 +85,18 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="log-header">
|
||||
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">Server Logs</h3>
|
||||
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">{t("logsView.title")}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
{t("logsView.actions.show")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||
Hide server logs
|
||||
{t("logsView.actions.hide")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -103,7 +105,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
||||
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
|
||||
<div class="env-vars-container">
|
||||
<div class="env-vars-title">
|
||||
Environment Variables ({Object.keys(instance()?.environmentVariables!).length})
|
||||
{t("logsView.envVars.title", { count: Object.keys(instance()?.environmentVariables!).length })}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={Object.entries(instance()?.environmentVariables!)}>
|
||||
@@ -130,17 +132,17 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<div class="log-paused-state">
|
||||
<p class="log-paused-title">Server logs are paused</p>
|
||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
||||
<p class="log-paused-title">{t("logsView.paused.title")}</p>
|
||||
<p class="log-paused-description">{t("logsView.paused.description")}</p>
|
||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
{t("logsView.actions.show")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={logs().length > 0}
|
||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
||||
fallback={<div class="log-empty-state">{t("logsView.empty.waiting")}</div>}
|
||||
>
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
@@ -160,7 +162,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
||||
class="scroll-to-bottom"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
Scroll to bottom
|
||||
{t("logsView.scrollToBottom")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -34,6 +35,7 @@ interface MarkdownProps {
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const { t } = useI18n()
|
||||
const [html, setHtml] = createSignal("")
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let latestRequestedText = ""
|
||||
@@ -70,6 +72,9 @@ export function Markdown(props: MarkdownProps) {
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
|
||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
@@ -145,14 +150,14 @@ export function Markdown(props: MarkdownProps) {
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (copyText) {
|
||||
if (success) {
|
||||
copyText.textContent = "Copied!"
|
||||
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||
}, 2000)
|
||||
} else {
|
||||
copyText.textContent = "Failed"
|
||||
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
@@ -169,6 +174,8 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { FoldVertical } from "lucide-solid"
|
||||
import { ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -11,6 +11,9 @@ import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
@@ -171,21 +174,256 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||
interface ContentDisplayItem {
|
||||
type: "content"
|
||||
key: string
|
||||
record: MessageRecord
|
||||
parts: ClientPart[]
|
||||
messageInfo?: MessageInfo
|
||||
isQueued: boolean
|
||||
showAgentMeta?: boolean
|
||||
messageId: string
|
||||
startPartId: string
|
||||
}
|
||||
|
||||
interface ToolDisplayItem {
|
||||
type: "tool"
|
||||
key: string
|
||||
toolPart: ToolCallPart
|
||||
messageInfo?: MessageInfo
|
||||
messageId: string
|
||||
messageVersion: number
|
||||
partVersion: number
|
||||
partId: string
|
||||
}
|
||||
|
||||
interface MessageContentItemProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageId: string
|
||||
startPartId: string
|
||||
messageIndex: number
|
||||
lastAssistantIndex: () => number
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
function MessageContentItem(props: MessageContentItemProps) {
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
|
||||
const isQueued = createMemo(() => {
|
||||
const current = record()
|
||||
if (!current) return false
|
||||
if (current.role !== "user") return false
|
||||
const lastAssistant = props.lastAssistantIndex()
|
||||
return lastAssistant === -1 || props.messageIndex > lastAssistant
|
||||
})
|
||||
|
||||
const parts = createMemo<ClientPart[]>(() => {
|
||||
const current = record()
|
||||
if (!current) return []
|
||||
const ids = current.partIds
|
||||
const startIndex = ids.indexOf(props.startPartId)
|
||||
if (startIndex === -1) return []
|
||||
|
||||
const resolved: ClientPart[] = []
|
||||
for (let idx = startIndex; idx < ids.length; idx++) {
|
||||
const partId = ids[idx]
|
||||
const part = current.parts[partId]?.data
|
||||
if (!part) continue
|
||||
if (
|
||||
part.type === "tool" ||
|
||||
part.type === "reasoning" ||
|
||||
part.type === "compaction" ||
|
||||
part.type === "step-start" ||
|
||||
part.type === "step-finish"
|
||||
) {
|
||||
break
|
||||
}
|
||||
resolved.push(part)
|
||||
}
|
||||
|
||||
return resolved
|
||||
})
|
||||
|
||||
const showAgentMeta = createMemo(() => {
|
||||
const current = record()
|
||||
if (!current) return false
|
||||
if (current.role !== "assistant") return false
|
||||
|
||||
const currentParts = parts()
|
||||
if (!currentParts.some((part) => partHasRenderableText(part))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ids = current.partIds
|
||||
const startIndex = ids.indexOf(props.startPartId)
|
||||
if (startIndex === -1) return false
|
||||
|
||||
// Only show agent meta on the first content segment that contains renderable content.
|
||||
for (let idx = 0; idx < startIndex; idx++) {
|
||||
const partId = ids[idx]
|
||||
const part = current.parts[partId]?.data
|
||||
if (!part) continue
|
||||
if (
|
||||
part.type === "tool" ||
|
||||
part.type === "reasoning" ||
|
||||
part.type === "compaction" ||
|
||||
part.type === "step-start" ||
|
||||
part.type === "step-finish"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (partHasRenderableText(part)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={record()}>
|
||||
{(resolvedRecord) => (
|
||||
<MessageItem
|
||||
record={resolvedRecord()}
|
||||
messageInfo={messageInfo()}
|
||||
parts={parts()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={isQueued()}
|
||||
showAgentMeta={showAgentMeta()}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolCallItemProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageId: string
|
||||
partId: string
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
function ToolCallItem(props: ToolCallItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
const partEntry = createMemo(() => record()?.parts?.[props.partId])
|
||||
|
||||
const toolPart = createMemo(() => {
|
||||
const part = partEntry()?.data as ClientPart | undefined
|
||||
if (!part || part.type !== "tool") return undefined
|
||||
return part as ToolCallPart
|
||||
})
|
||||
|
||||
const toolState = createMemo(() => toolPart()?.state as ToolState | undefined)
|
||||
const toolName = createMemo(() => toolPart()?.tool || "")
|
||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const deleteDisabled = createMemo(() => {
|
||||
if (deleting()) return true
|
||||
// Avoid deleting while a tool is actively running to prevent confusing UI states.
|
||||
if (isToolStateRunning(toolState())) return true
|
||||
// Avoid deleting permission prompts from here; those are interactive.
|
||||
return Boolean(toolPart()?.pendingPermission)
|
||||
})
|
||||
|
||||
const taskSessionId = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return ""
|
||||
if (!(isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))) {
|
||||
return ""
|
||||
}
|
||||
return extractTaskSessionId(state)
|
||||
})
|
||||
|
||||
const taskLocation = createMemo(() => {
|
||||
const id = taskSessionId()
|
||||
if (!id) return null
|
||||
return findTaskSessionLocation(id, props.instanceId)
|
||||
})
|
||||
|
||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const location = taskLocation()
|
||||
if (!location) return
|
||||
navigateToTaskSession(location)
|
||||
}
|
||||
|
||||
const handleDeleteToolPart = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (deleteDisabled()) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
|
||||
title: t("messageBlock.tool.deletePart.failed.title"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={toolPart()}>
|
||||
{(resolvedToolPart) => (
|
||||
<>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||
<span>{t("messageBlock.tool.header")}</span>
|
||||
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={taskSessionId()}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation()}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={t("messageBlock.tool.goToSession.label")}
|
||||
aria-label={t("messageBlock.tool.goToSession.label")}
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={deleteDisabled()}
|
||||
onClick={handleDeleteToolPart}
|
||||
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolCall
|
||||
toolCall={resolvedToolPart()}
|
||||
toolCallId={props.partId}
|
||||
messageId={props.messageId}
|
||||
messageVersion={messageVersion()}
|
||||
partVersion={partVersion()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepDisplayItem {
|
||||
@@ -203,6 +441,8 @@ type ReasoningDisplayItem = {
|
||||
messageInfo?: MessageInfo
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded: boolean
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
type CompactionDisplayItem = {
|
||||
@@ -211,6 +451,8 @@ type CompactionDisplayItem = {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
accentColor?: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
|
||||
@@ -236,6 +478,7 @@ interface MessageBlockProps {
|
||||
}
|
||||
|
||||
export default function MessageBlock(props: MessageBlockProps) {
|
||||
const { t } = useI18n()
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||
@@ -270,7 +513,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const items: MessageBlockItem[] = []
|
||||
const blockContentKeys: string[] = []
|
||||
const blockToolKeys: string[] = []
|
||||
let segmentIndex = 0
|
||||
let pendingParts: ClientPart[] = []
|
||||
let agentMetaAttached = current.role !== "assistant"
|
||||
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
|
||||
@@ -278,34 +520,28 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
|
||||
const flushContent = () => {
|
||||
if (pendingParts.length === 0) return
|
||||
const segmentKey = `${current.id}:segment:${segmentIndex}`
|
||||
segmentIndex += 1
|
||||
const shouldShowAgentMeta =
|
||||
current.role === "assistant" &&
|
||||
!agentMetaAttached &&
|
||||
pendingParts.some((part) => partHasRenderableText(part))
|
||||
const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : ""
|
||||
if (!startPartId) {
|
||||
pendingParts = []
|
||||
return
|
||||
}
|
||||
|
||||
if (!agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part))) {
|
||||
agentMetaAttached = true
|
||||
}
|
||||
|
||||
const segmentKey = `${current.id}:content:${startPartId}`
|
||||
let cached = sessionCache.messageItems.get(segmentKey)
|
||||
if (!cached) {
|
||||
cached = {
|
||||
type: "content",
|
||||
key: segmentKey,
|
||||
record: current,
|
||||
parts: pendingParts.slice(),
|
||||
messageInfo: info,
|
||||
isQueued,
|
||||
showAgentMeta: shouldShowAgentMeta,
|
||||
messageId: current.id,
|
||||
startPartId,
|
||||
}
|
||||
sessionCache.messageItems.set(segmentKey, cached)
|
||||
} else {
|
||||
cached.record = current
|
||||
cached.parts = pendingParts.slice()
|
||||
cached.messageInfo = info
|
||||
cached.isQueued = isQueued
|
||||
cached.showAgentMeta = shouldShowAgentMeta
|
||||
}
|
||||
if (shouldShowAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
}
|
||||
|
||||
items.push(cached)
|
||||
blockContentKeys.push(segmentKey)
|
||||
lastAccentColor = defaultAccentColor
|
||||
@@ -315,28 +551,26 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
orderedParts.forEach((part, partIndex) => {
|
||||
if (part.type === "tool") {
|
||||
flushContent()
|
||||
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
|
||||
const messageVersion = current.revision
|
||||
const key = `${current.id}:${part.id ?? partIndex}`
|
||||
const partId = part.id
|
||||
if (!partId) {
|
||||
// Tool parts are required to have ids; if one slips through, skip rendering
|
||||
// to avoid unstable keys and accidental remount cascades.
|
||||
return
|
||||
}
|
||||
const key = `${current.id}:${partId}`
|
||||
let toolItem = sessionCache.toolItems.get(key)
|
||||
if (!toolItem) {
|
||||
toolItem = {
|
||||
type: "tool",
|
||||
key,
|
||||
toolPart: part as ToolCallPart,
|
||||
messageInfo: info,
|
||||
messageId: current.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
partId,
|
||||
}
|
||||
sessionCache.toolItems.set(key, toolItem)
|
||||
} else {
|
||||
toolItem.key = key
|
||||
toolItem.toolPart = part as ToolCallPart
|
||||
toolItem.messageInfo = info
|
||||
toolItem.messageId = current.id
|
||||
toolItem.messageVersion = messageVersion
|
||||
toolItem.partVersion = partVersion
|
||||
toolItem.partId = partId
|
||||
}
|
||||
items.push(toolItem)
|
||||
blockToolKeys.push(key)
|
||||
@@ -346,7 +580,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushContent()
|
||||
const key = `${current.id}:${part.id ?? partIndex}:compaction`
|
||||
const partId = part.id ?? ""
|
||||
const key = `${current.id}:${partId || partIndex}:compaction`
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
items.push({
|
||||
type: "compaction",
|
||||
@@ -354,6 +589,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
part,
|
||||
messageInfo: info,
|
||||
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
|
||||
messageId: current.id,
|
||||
partId,
|
||||
})
|
||||
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
|
||||
return
|
||||
@@ -378,7 +615,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
if (part.type === "reasoning") {
|
||||
flushContent()
|
||||
if (props.showThinking() && reasoningHasRenderableContent(part)) {
|
||||
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
|
||||
const partId = part.id ?? ""
|
||||
const key = `${current.id}:${partId || partIndex}:reasoning`
|
||||
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
|
||||
if (showAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
@@ -390,6 +628,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
messageInfo: info,
|
||||
showAgentMeta,
|
||||
defaultExpanded: props.thinkingDefaultExpanded(),
|
||||
messageId: current.id,
|
||||
partId,
|
||||
})
|
||||
lastAccentColor = ASSISTANT_BORDER_COLOR
|
||||
}
|
||||
@@ -425,21 +665,21 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={block()} keyed>
|
||||
<Show when={block()}>
|
||||
{(resolvedBlock) => (
|
||||
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
|
||||
<For each={resolvedBlock.items}>
|
||||
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
|
||||
<For each={resolvedBlock().items}>
|
||||
{(item) => (
|
||||
<Switch>
|
||||
<Match when={item.type === "content"}>
|
||||
<MessageItem
|
||||
record={(item as ContentDisplayItem).record}
|
||||
messageInfo={(item as ContentDisplayItem).messageInfo}
|
||||
parts={(item as ContentDisplayItem).parts}
|
||||
<MessageContentItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={(item as ContentDisplayItem).isQueued}
|
||||
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
||||
store={props.store}
|
||||
messageId={(item as ContentDisplayItem).messageId}
|
||||
startPartId={(item as ContentDisplayItem).startPartId}
|
||||
messageIndex={props.messageIndex}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
@@ -448,46 +688,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
<Match when={item.type === "tool"}>
|
||||
{(() => {
|
||||
const toolItem = item as ToolDisplayItem
|
||||
const toolState = toolItem.toolPart.state as ToolState | undefined
|
||||
const hasToolState =
|
||||
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
|
||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!taskLocation) return
|
||||
navigateToTaskSession(taskLocation)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-message" data-key={toolItem.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={toolItem.toolPart}
|
||||
toolCallId={toolItem.toolPart.id}
|
||||
messageId={toolItem.messageId}
|
||||
messageVersion={toolItem.messageVersion}
|
||||
partVersion={toolItem.partVersion}
|
||||
<ToolCallItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageId={toolItem.messageId}
|
||||
partId={toolItem.partId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
@@ -495,7 +703,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||
<StepCard
|
||||
kind="start"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
showAgentMeta
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<StepCard
|
||||
@@ -507,7 +720,15 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
|
||||
<CompactionCard
|
||||
part={(item as CompactionDisplayItem).part}
|
||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as CompactionDisplayItem).messageId}
|
||||
partId={(item as CompactionDisplayItem).partId}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
@@ -515,6 +736,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as ReasoningDisplayItem).messageId}
|
||||
partId={(item as ReasoningDisplayItem).partId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
/>
|
||||
@@ -537,21 +760,63 @@ interface StepCardProps {
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
||||
interface CompactionCardProps {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
borderColor?: string
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: CompactionCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you")
|
||||
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
|
||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||
|
||||
const containerClass = () =>
|
||||
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
||||
|
||||
const canDelete = () => Boolean(props.partId) && !deleting()
|
||||
|
||||
const handleDelete = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={containerClass()}
|
||||
class={`${containerClass()} relative`}
|
||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||
role="status"
|
||||
aria-label="Session compaction"
|
||||
aria-label={t("messageBlock.compaction.ariaLabel")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
|
||||
disabled={!canDelete()}
|
||||
onClick={handleDelete}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</button>
|
||||
|
||||
<div class="message-compaction-row">
|
||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||
<span class="message-compaction-label">{label()}</span>
|
||||
@@ -561,6 +826,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
|
||||
}
|
||||
|
||||
function StepCard(props: StepCardProps) {
|
||||
const { t } = useI18n()
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
@@ -605,14 +871,15 @@ function StepCard(props: StepCardProps) {
|
||||
|
||||
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
||||
|
||||
|
||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||
const entries = [
|
||||
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
|
||||
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
|
||||
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
|
||||
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
|
||||
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
|
||||
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
|
||||
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
|
||||
{ label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal },
|
||||
{ label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
|
||||
{ label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
|
||||
{ label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
|
||||
{ label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -647,8 +914,8 @@ function StepCard(props: StepCardProps) {
|
||||
<div class="message-step-title-left">
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
||||
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span>{t("messageBlock.step.modelLabel", { model: value() })}</span>}</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -670,12 +937,16 @@ interface ReasoningCardProps {
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
@@ -739,6 +1010,27 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
const toggle = () => setExpanded((prev) => !prev)
|
||||
|
||||
const hasDeleteTarget = () => Boolean(props.partId)
|
||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
||||
|
||||
const handleDelete = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-reasoning-card">
|
||||
<button
|
||||
@@ -746,19 +1038,48 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
class="message-reasoning-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
|
||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||
>
|
||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||
<span>Thinking</span>
|
||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={modelIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="message-reasoning-meta">
|
||||
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
|
||||
<span class="message-reasoning-indicator">
|
||||
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
|
||||
</span>
|
||||
|
||||
<Show when={hasDeleteTarget()}>
|
||||
<span
|
||||
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDelete}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
handleDelete(event)
|
||||
}
|
||||
}}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<span class="message-reasoning-time">{timestamp()}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -766,7 +1087,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<Show when={expanded()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
|
||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Copy, Split, Trash2, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -19,7 +23,9 @@ interface MessageItemProps {
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
@@ -49,15 +55,15 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
const url = part.url || ""
|
||||
if (url.startsWith("data:")) {
|
||||
return "attachment"
|
||||
return t("messageItem.attachment.defaultName")
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const segments = parsed.pathname.split("/")
|
||||
return segments.pop() || "attachment"
|
||||
return segments.pop() || t("messageItem.attachment.defaultName")
|
||||
} catch (error) {
|
||||
const fallback = url.split("/").pop()
|
||||
return fallback && fallback.length > 0 ? fallback : "attachment"
|
||||
return fallback && fallback.length > 0 ? fallback : t("messageItem.attachment.defaultName")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +118,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
const error = info.error
|
||||
if (error.name === "ProviderAuthError") {
|
||||
return error.data?.message || "Authentication error"
|
||||
return error.data?.message || t("messageItem.errors.authenticationFallback")
|
||||
}
|
||||
if (error.name === "MessageOutputLengthError") {
|
||||
return "Message output length exceeded"
|
||||
return t("messageItem.errors.outputLengthExceeded")
|
||||
}
|
||||
if (error.name === "MessageAbortedError") {
|
||||
return "Request was aborted"
|
||||
return t("messageItem.errors.requestAborted")
|
||||
}
|
||||
if (error.name === "UnknownError") {
|
||||
return error.data?.message || "Unknown error occurred"
|
||||
return error.data?.message || t("messageItem.errors.unknownFallback")
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -135,8 +141,17 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
|
||||
const isGenerating = () => {
|
||||
if (hasContent()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Prefer the local record status for streaming placeholders.
|
||||
if (!isUser() && props.record.status === "streaming") {
|
||||
return true
|
||||
}
|
||||
|
||||
const info = props.messageInfo
|
||||
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
|
||||
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
|
||||
}
|
||||
|
||||
const handleRevert = () => {
|
||||
@@ -145,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")
|
||||
@@ -161,7 +178,51 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent()) {
|
||||
const deletableTextPartId = () => {
|
||||
const part = props.parts.find((candidate) => {
|
||||
if (!candidate || candidate.type !== "text") return false
|
||||
const id = (candidate as any).id
|
||||
if (typeof id !== "string" || id.length === 0) return false
|
||||
return !Boolean((candidate as any).synthetic)
|
||||
})
|
||||
return (part as any)?.id as string | undefined
|
||||
}
|
||||
|
||||
const isDeletingPart = (partId?: string) => {
|
||||
if (!partId) return false
|
||||
return deletingParts().has(partId)
|
||||
}
|
||||
|
||||
const setPartDeleting = (partId: string, value: boolean) => {
|
||||
setDeletingParts((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) {
|
||||
next.add(partId)
|
||||
} else {
|
||||
next.delete(partId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePart = async (partId?: string) => {
|
||||
if (!partId) return
|
||||
if (isDeletingPart(partId)) return
|
||||
setPartDeleting(partId, true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setPartDeleting(partId, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -170,7 +231,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||
|
||||
const speakerLabel = () => (isUser() ? "You" : "Assistant")
|
||||
const speakerLabel = () => (isUser() ? t("messageItem.speaker.you") : t("messageItem.speaker.assistant"))
|
||||
|
||||
const agentIdentifier = () => {
|
||||
if (isUser()) return ""
|
||||
@@ -195,10 +256,10 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
const agent = agentIdentifier()
|
||||
const model = modelIdentifier()
|
||||
if (agent) {
|
||||
segments.push(`Agent: ${agent}`)
|
||||
segments.push(t("messageItem.agentMeta.agentLabel", { agent }))
|
||||
}
|
||||
if (model) {
|
||||
segments.push(`Model: ${model}`)
|
||||
segments.push(t("messageItem.agentMeta.modelLabel", { model }))
|
||||
}
|
||||
return segments.join(" • ")
|
||||
}
|
||||
@@ -220,45 +281,70 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title="Revert to this message"
|
||||
aria-label="Revert to this message"
|
||||
title={t("messageItem.actions.revert")}
|
||||
aria-label={t("messageItem.actions.revert")}
|
||||
>
|
||||
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="Fork from this message"
|
||||
aria-label="Fork from this message"
|
||||
title={t("messageItem.actions.fork")}
|
||||
aria-label={t("messageItem.actions.fork")}
|
||||
>
|
||||
Fork
|
||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
<div class="message-action-group">
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
@@ -269,7 +355,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
|
||||
<Show when={props.isQueued && isUser()}>
|
||||
<div class="message-queued-badge">QUEUED</div>
|
||||
<div class="message-queued-badge">{t("messageItem.status.queued")}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
@@ -278,7 +364,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
<Show when={isGenerating()}>
|
||||
<div class="message-generating">
|
||||
<span class="generating-spinner">⏳</span> Generating...
|
||||
<span class="generating-spinner">⏳</span> {t("messageItem.status.generating")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -319,13 +405,26 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
class="attachment-download"
|
||||
aria-label={`Download ${name}`}
|
||||
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeletePart(attachment.id)}
|
||||
class="attachment-remove"
|
||||
disabled={isDeletingPart(attachment.id)}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
@@ -340,12 +439,12 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
<Show when={props.record.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.record.status === "error"}>
|
||||
<div class="message-error">⚠ Message failed to send</div>
|
||||
<div class="message-error">⚠ {t("messageItem.status.failedToSend")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Show } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
@@ -17,6 +18,7 @@ interface MessageListHeaderProps {
|
||||
}
|
||||
|
||||
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||
@@ -29,7 +31,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
type="button"
|
||||
class="session-sidebar-menu-button"
|
||||
onClick={() => props.onSidebarToggle?.()}
|
||||
aria-label="Open session list"
|
||||
aria-label={t("messageListHeader.sidebar.openSessionListAriaLabel")}
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
@@ -39,11 +41,11 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
<div class="connection-status-text connection-status-info">
|
||||
<div class="connection-status-usage">
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Used</span>
|
||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Avail</span>
|
||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,8 +53,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
|
||||
<div class="connection-status-text connection-status-shortcut">
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
||||
Command Palette
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button"
|
||||
onClick={props.onCommandPalette}
|
||||
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||
>
|
||||
{t("messageListHeader.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
@@ -64,19 +71,19 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
<Show when={props.connectionStatus === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connected</span>
|
||||
<span class="status-text">{t("messageListHeader.connection.connected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connecting...</span>
|
||||
<span class="status-text">{t("messageListHeader.connection.connecting")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Disconnected</span>
|
||||
<span class="status-text">{t("messageListHeader.connection.disconnected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MessagePartProps {
|
||||
sessionId: string
|
||||
onRendered?: () => void
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
@@ -25,6 +25,14 @@ interface MessagePartProps {
|
||||
const isAssistantMessage = () => props.messageType === "assistant"
|
||||
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
||||
|
||||
const shouldHideTextPart = () => {
|
||||
const part = props.part
|
||||
if (!part || part.type !== "text") return false
|
||||
// Keep optimistic user prompts visible; hide synthetic assistant text.
|
||||
return Boolean((part as any).synthetic) && props.messageType !== "user"
|
||||
}
|
||||
|
||||
|
||||
const plainTextContent = () => {
|
||||
const part = props.part
|
||||
|
||||
@@ -94,23 +102,23 @@ interface MessagePartProps {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={partType() === "text"}>
|
||||
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
|
||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||
<div class={textContainerClass()}>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span>{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span class="text-primary">{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useConfig } from "../stores/preferences"
|
||||
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"
|
||||
@@ -31,6 +34,7 @@ export interface MessageSectionProps {
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
const { preferences } = useConfig()
|
||||
const { t } = useI18n()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
@@ -107,7 +111,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
seenTimelineMessageIds.add(messageId)
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
@@ -121,7 +125,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
if (built.length === 0) return
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
@@ -373,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 })
|
||||
}
|
||||
@@ -392,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) {
|
||||
@@ -558,7 +582,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
@@ -753,19 +777,19 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<h3>{t("messageSection.empty.title")}</h3>
|
||||
<p>{t("messageSection.empty.description")}</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
{t("messageSection.empty.tips.attachFilesPrefix")} <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -775,7 +799,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
<p>{t("messageSection.loading.messages")}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -803,7 +827,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={t("messageSection.scroll.toFirstAriaLabel")}>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
@@ -812,7 +836,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||
aria-label="Scroll to latest message"
|
||||
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
@@ -828,10 +852,13 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
>
|
||||
<div class="message-quote-button-group">
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
||||
Add as quote
|
||||
{t("messageSection.quote.addAsQuote")}
|
||||
</button>
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||
Add as code
|
||||
{t("messageSection.quote.addAsCode")}
|
||||
</button>
|
||||
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
|
||||
{t("messageSection.quote.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||
|
||||
@@ -29,14 +30,6 @@ interface MessageTimelineProps {
|
||||
showToolSegments?: boolean
|
||||
}
|
||||
|
||||
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||
user: "You",
|
||||
assistant: "Asst",
|
||||
tool: "Tool",
|
||||
compaction: "Compaction",
|
||||
}
|
||||
|
||||
const TOOL_FALLBACK_LABEL = "Tool Call"
|
||||
const MAX_TOOLTIP_LENGTH = 220
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
@@ -90,7 +83,7 @@ function collectReasoningText(part: ClientPart): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
function collectTextFromPart(part: ClientPart): string {
|
||||
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||
if (!part) return ""
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text as string
|
||||
@@ -106,26 +99,28 @@ function collectTextFromPart(part: ClientPart): string {
|
||||
}
|
||||
if (part.type === "file") {
|
||||
const filename = (part as any)?.filename
|
||||
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
|
||||
return typeof filename === "string" && filename.length > 0
|
||||
? t("messageTimeline.text.filePrefix", { filename })
|
||||
: t("messageTimeline.text.attachment")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function getToolTitle(part: ToolCallPart): string {
|
||||
function getToolTitle(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
|
||||
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
|
||||
if (title) return title
|
||||
if (typeof part.tool === "string" && part.tool.length > 0) {
|
||||
return part.tool
|
||||
}
|
||||
return TOOL_FALLBACK_LABEL
|
||||
return t("messageTimeline.tool.fallbackLabel")
|
||||
}
|
||||
|
||||
function getToolTypeLabel(part: ToolCallPart): string {
|
||||
function getToolTypeLabel(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
|
||||
return part.tool.trim().slice(0, 4)
|
||||
}
|
||||
return TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||
return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
|
||||
}
|
||||
|
||||
function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||
@@ -139,20 +134,34 @@ function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatToolTooltip(titles: string[]): string {
|
||||
function formatToolTooltip(
|
||||
titles: string[],
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (titles.length === 0) {
|
||||
return TOOL_FALLBACK_LABEL
|
||||
return t("messageTimeline.tool.fallbackLabel")
|
||||
}
|
||||
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
|
||||
return truncateText(`${t("messageTimeline.tool.fallbackLabel")}: ${titles.join(", ")}`)
|
||||
}
|
||||
|
||||
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
|
||||
export function buildTimelineSegments(
|
||||
instanceId: string,
|
||||
record: MessageRecord,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): TimelineSegment[] {
|
||||
if (!record) return []
|
||||
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
||||
if (!orderedParts || orderedParts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const segmentLabel = (type: TimelineSegmentType) => {
|
||||
if (type === "user") return t("messageTimeline.segment.user.label")
|
||||
if (type === "assistant") return t("messageTimeline.segment.assistant.label")
|
||||
if (type === "compaction") return t("messageTimeline.segment.compaction.label")
|
||||
return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
|
||||
}
|
||||
|
||||
const result: TimelineSegment[] = []
|
||||
let segmentIndex = 0
|
||||
let pending: PendingSegment | null = null
|
||||
@@ -164,14 +173,14 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
||||
}
|
||||
const isToolSegment = pending.type === "tool"
|
||||
const label = isToolSegment
|
||||
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||
: SEGMENT_LABELS[pending.type]
|
||||
? pending.toolTypeLabels[0] || segmentLabel("tool")
|
||||
: segmentLabel(pending.type)
|
||||
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||
const tooltip = isToolSegment
|
||||
? formatToolTooltip(pending.toolTitles)
|
||||
? formatToolTooltip(pending.toolTitles, t)
|
||||
: formatTextsTooltip(
|
||||
[...pending.texts, ...pending.reasoningTexts],
|
||||
pending.type === "user" ? "User message" : "Assistant response",
|
||||
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||
)
|
||||
|
||||
result.push({
|
||||
@@ -204,8 +213,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
||||
if (part.type === "tool") {
|
||||
const target = ensureSegment("tool")
|
||||
const toolPart = part as ToolCallPart
|
||||
target.toolTitles.push(getToolTitle(toolPart))
|
||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
||||
target.toolTitles.push(getToolTitle(toolPart, t))
|
||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
|
||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
||||
target.toolPartIds.push(toolPart.id)
|
||||
@@ -230,8 +239,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
type: "compaction",
|
||||
label: SEGMENT_LABELS.compaction,
|
||||
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
|
||||
label: segmentLabel("compaction"),
|
||||
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||
variant: isAuto ? "auto" : "manual",
|
||||
})
|
||||
segmentIndex += 1
|
||||
@@ -242,7 +251,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
||||
continue
|
||||
}
|
||||
|
||||
const text = collectTextFromPart(part)
|
||||
const text = collectTextFromPart(part, t)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
@@ -258,6 +267,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
||||
}
|
||||
|
||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const buttonRefs = new Map<string, HTMLButtonElement>()
|
||||
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
||||
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
||||
@@ -360,7 +370,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
||||
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
|
||||
<For each={props.segments}>
|
||||
{(segment) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
@@ -438,4 +448,3 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
export default MessageTimeline
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { ChevronDown, Star } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -21,10 +23,22 @@ interface FlatModel extends Model {
|
||||
}
|
||||
|
||||
export default function ModelSelector(props: ModelSelectorProps) {
|
||||
const { t } = useI18n()
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [manualAll, setManualAll] = createSignal(false)
|
||||
const [explicitFavorites, setExplicitFavorites] = createSignal(false)
|
||||
const [autoFavoritesEligibleAtOpen, setAutoFavoritesEligibleAtOpen] = createSignal(false)
|
||||
const [searchDirty, setSearchDirty] = createSignal(false)
|
||||
const [initialQuery, setInitialQuery] = createSignal("")
|
||||
const [initialQueryReady, setInitialQueryReady] = createSignal(false)
|
||||
const [inputValue, setInputValue] = createSignal("")
|
||||
let triggerRef!: HTMLButtonElement
|
||||
let searchInputRef!: HTMLInputElement
|
||||
let listboxRef!: HTMLUListElement
|
||||
let suppressNextClose = false
|
||||
let wasFavoritesOnlyEnabled = false
|
||||
let wasCurrentModelFavorite = false
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
@@ -43,61 +57,232 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
),
|
||||
)
|
||||
|
||||
const favoriteKeySet = createMemo(() => {
|
||||
const result = new Set<string>()
|
||||
for (const item of preferences().modelFavorites ?? []) {
|
||||
if (item.providerId && item.modelId) {
|
||||
result.add(`${item.providerId}/${item.modelId}`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const favoriteModels = createMemo<FlatModel[]>(() => {
|
||||
const keys = favoriteKeySet()
|
||||
if (keys.size === 0) return []
|
||||
return allModels().filter((m) => keys.has(m.key))
|
||||
})
|
||||
|
||||
const hasFavorites = createMemo(() => favoriteModels().length > 0)
|
||||
|
||||
const currentModelValue = createMemo(() =>
|
||||
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
|
||||
)
|
||||
|
||||
const currentModelIsFavorite = createMemo(() => {
|
||||
const current = props.currentModel
|
||||
return favoriteKeySet().has(`${current.providerId}/${current.modelId}`)
|
||||
})
|
||||
|
||||
const currentModelKey = createMemo(() => {
|
||||
const current = props.currentModel
|
||||
return `${current.providerId}/${current.modelId}`
|
||||
})
|
||||
|
||||
const searchActive = createMemo(() => {
|
||||
if (!searchDirty()) return false
|
||||
const next = inputValue().trim()
|
||||
return next.length > 0
|
||||
})
|
||||
|
||||
const favoritesOnlyEnabled = createMemo(() => {
|
||||
if (searchActive()) return false
|
||||
if (manualAll()) return false
|
||||
if (!hasFavorites()) return false
|
||||
return explicitFavorites() || autoFavoritesEligibleAtOpen()
|
||||
})
|
||||
|
||||
const visibleOptions = createMemo<FlatModel[]>(() => {
|
||||
if (!favoritesOnlyEnabled()) {
|
||||
return allModels()
|
||||
}
|
||||
return favoriteModels()
|
||||
})
|
||||
|
||||
const handleChange = async (value: FlatModel | null) => {
|
||||
if (!value) return
|
||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||
}
|
||||
|
||||
const customFilter = (option: FlatModel, inputValue: string) => {
|
||||
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
|
||||
const customFilter = (option: FlatModel, rawInput: string) => {
|
||||
if (!searchDirty()) return true
|
||||
return option.searchText.toLowerCase().includes(rawInput.toLowerCase())
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
setManualAll(false)
|
||||
setExplicitFavorites(false)
|
||||
setAutoFavoritesEligibleAtOpen(hasFavorites() && currentModelIsFavorite())
|
||||
setSearchDirty(false)
|
||||
setInitialQuery("")
|
||||
setInputValue("")
|
||||
setInitialQueryReady(false)
|
||||
setTimeout(() => {
|
||||
const seeded = searchInputRef?.value ?? ""
|
||||
setInitialQuery(seeded)
|
||||
setInputValue(seeded)
|
||||
setInitialQueryReady(true)
|
||||
searchInputRef?.focus()
|
||||
searchInputRef?.select()
|
||||
}, 100)
|
||||
} else {
|
||||
setInitialQueryReady(false)
|
||||
setSearchDirty(false)
|
||||
setAutoFavoritesEligibleAtOpen(false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!isOpen()) {
|
||||
wasFavoritesOnlyEnabled = favoritesOnlyEnabled()
|
||||
wasCurrentModelFavorite = currentModelIsFavorite()
|
||||
return
|
||||
}
|
||||
|
||||
const nowFavoritesOnlyEnabled = favoritesOnlyEnabled()
|
||||
const nowCurrentModelFavorite = currentModelIsFavorite()
|
||||
|
||||
if (wasFavoritesOnlyEnabled && !nowFavoritesOnlyEnabled && wasCurrentModelFavorite && !nowCurrentModelFavorite) {
|
||||
setTimeout(() => {
|
||||
const key = currentModelKey()
|
||||
const target = listboxRef?.querySelector(`[data-key="${key}"]`) as HTMLElement | null
|
||||
target?.scrollIntoView({ block: "nearest" })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
wasFavoritesOnlyEnabled = nowFavoritesOnlyEnabled
|
||||
wasCurrentModelFavorite = nowCurrentModelFavorite
|
||||
})
|
||||
|
||||
const handleSearchInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
|
||||
const next = event.currentTarget.value
|
||||
setInputValue(next)
|
||||
if (!initialQueryReady()) return
|
||||
if (searchDirty()) return
|
||||
if (next !== initialQuery()) {
|
||||
setSearchDirty(true)
|
||||
}
|
||||
}
|
||||
|
||||
const preventListboxPress = (event: PointerEvent | MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation?.()
|
||||
event.stopPropagation()
|
||||
suppressNextClose = true
|
||||
setTimeout(() => {
|
||||
suppressNextClose = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const toggleFavoritesOnly = () => {
|
||||
if (!hasFavorites()) return
|
||||
if (searchActive()) return
|
||||
|
||||
if (favoritesOnlyEnabled()) {
|
||||
setManualAll(true)
|
||||
setExplicitFavorites(false)
|
||||
setAutoFavoritesEligibleAtOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
setExplicitFavorites(true)
|
||||
setManualAll(false)
|
||||
}
|
||||
|
||||
const showAllModels = () => {
|
||||
setManualAll(true)
|
||||
setExplicitFavorites(false)
|
||||
setAutoFavoritesEligibleAtOpen(false)
|
||||
setTimeout(() => searchInputRef?.focus(), 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
<Combobox<FlatModel>
|
||||
open={isOpen()}
|
||||
value={currentModelValue()}
|
||||
onChange={handleChange}
|
||||
onOpenChange={setIsOpen}
|
||||
options={allModels()}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && suppressNextClose) return
|
||||
setIsOpen(next)
|
||||
}}
|
||||
options={visibleOptions()}
|
||||
optionValue="key"
|
||||
optionTextValue="searchText"
|
||||
optionLabel="name"
|
||||
placeholder="Search models..."
|
||||
placeholder={t("modelSelector.placeholder.search")}
|
||||
defaultFilter={customFilter}
|
||||
allowsEmptyCollection
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item
|
||||
item={itemProps.item}
|
||||
class="selector-option"
|
||||
>
|
||||
<div class="selector-option-content">
|
||||
<Combobox.ItemLabel class="selector-option-label">
|
||||
{itemProps.item.rawValue.name}
|
||||
</Combobox.ItemLabel>
|
||||
<Combobox.ItemDescription class="selector-option-description">
|
||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/
|
||||
{itemProps.item.rawValue.id}
|
||||
</Combobox.ItemDescription>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
itemComponent={(itemProps) => {
|
||||
const isFavorite = () => favoriteKeySet().has(itemProps.item.rawValue.key)
|
||||
return (
|
||||
<Combobox.Item
|
||||
item={itemProps.item}
|
||||
class="selector-option"
|
||||
>
|
||||
<>
|
||||
<div class="selector-option-content">
|
||||
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Combobox.ItemLabel>
|
||||
<Combobox.ItemDescription class="selector-option-description">
|
||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
|
||||
</Combobox.ItemDescription>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option-star"
|
||||
data-active={isFavorite()}
|
||||
aria-label={
|
||||
isFavorite()
|
||||
? t("modelSelector.favorite.remove")
|
||||
: t("modelSelector.favorite.add")
|
||||
}
|
||||
onPointerDown={preventListboxPress}
|
||||
onPointerUp={preventListboxPress}
|
||||
onMouseDown={preventListboxPress}
|
||||
onMouseUp={preventListboxPress}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressNextClose = true
|
||||
setTimeout(() => {
|
||||
suppressNextClose = false
|
||||
}, 0)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoriteModelPreference({
|
||||
providerId: itemProps.item.rawValue.providerId,
|
||||
modelId: itemProps.item.rawValue.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
class="w-4 h-4"
|
||||
fill={isFavorite() ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
</Combobox.Item>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-model-selector-control>
|
||||
<Combobox.Input class="sr-only" data-model-selector />
|
||||
@@ -105,11 +290,11 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
ref={triggerRef}
|
||||
class="selector-trigger"
|
||||
>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
Model: {currentModelValue()?.name ?? "None"}
|
||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary">
|
||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||
</span>
|
||||
@@ -124,13 +309,53 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="selector-popover">
|
||||
<div class="selector-search-container">
|
||||
<Combobox.Input
|
||||
ref={searchInputRef}
|
||||
class="selector-search-input"
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
<div class="selector-input-group">
|
||||
<Combobox.Input
|
||||
ref={searchInputRef}
|
||||
class="selector-search-input flex-1 min-w-0"
|
||||
placeholder={t("modelSelector.placeholder.search")}
|
||||
onInput={handleSearchInput}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-favorites-toggle"
|
||||
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
|
||||
aria-pressed={favoritesOnlyEnabled()}
|
||||
disabled={!hasFavorites() || searchActive()}
|
||||
data-active={favoritesOnlyEnabled()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoritesOnly()
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
|
||||
<div class="selector-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option selector-option-action w-full"
|
||||
style={{ display: favoritesOnlyEnabled() && !searchActive() ? "flex" : "none" }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
showAllModels()
|
||||
}}
|
||||
>
|
||||
<span class="selector-option-label">{t("modelSelector.favoritesOnly.showAll")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Combobox.Listbox class="selector-listbox" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -23,6 +24,7 @@ interface OpenCodeBinarySelectorProps {
|
||||
}
|
||||
|
||||
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
@@ -103,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
}
|
||||
|
||||
if (validatingPaths().has(path)) {
|
||||
return { valid: false, error: "Already validating" }
|
||||
return { valid: false, error: t("opencodeBinarySelector.validation.alreadyValidating") }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -139,7 +141,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
setValidationError(null)
|
||||
if (nativeDialogsAvailable) {
|
||||
const selected = await openNativeFileDialog({
|
||||
title: "Select OpenCode Binary",
|
||||
title: t("opencodeBinarySelector.dialog.title"),
|
||||
})
|
||||
if (selected) {
|
||||
setCustomPath(selected)
|
||||
@@ -160,7 +162,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
setCustomPath("")
|
||||
setValidationError(null)
|
||||
} else {
|
||||
setValidationError(validation.error || "Invalid OpenCode binary")
|
||||
setValidationError(validation.error || t("opencodeBinarySelector.validation.invalidBinary"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,14 +204,14 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||
return t("time.relative.justNow")
|
||||
}
|
||||
|
||||
function getDisplayName(path: string): string {
|
||||
if (path === "opencode") return "opencode (system PATH)"
|
||||
if (path === "opencode") return t("opencodeBinarySelector.display.systemPath", { name: "opencode" })
|
||||
const parts = path.split(/[/\\]/)
|
||||
return parts[parts.length - 1] ?? path
|
||||
}
|
||||
@@ -221,13 +223,13 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
<div class="panel">
|
||||
<div class="panel-header flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="panel-title">OpenCode Binary</h3>
|
||||
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
||||
<h3 class="panel-title">{t("opencodeBinarySelector.title")}</h3>
|
||||
<p class="panel-subtitle">{t("opencodeBinarySelector.subtitle")}</p>
|
||||
</div>
|
||||
<Show when={validating()}>
|
||||
<div class="selector-loading text-xs">
|
||||
<Loader2 class="selector-loading-spinner" />
|
||||
<span>Checking versions…</span>
|
||||
<span>{t("opencodeBinarySelector.status.checkingVersions")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -245,7 +247,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
}
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
placeholder="Enter path to opencode binary…"
|
||||
placeholder={t("opencodeBinarySelector.customPath.placeholder")}
|
||||
class="selector-input"
|
||||
/>
|
||||
<button
|
||||
@@ -255,7 +257,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
class="selector-button selector-button-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
{t("opencodeBinarySelector.actions.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +268,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
Browse for Binary…
|
||||
{t("opencodeBinarySelector.actions.browse")}
|
||||
</button>
|
||||
|
||||
<Show when={validationError()}>
|
||||
@@ -308,16 +310,16 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
</Show>
|
||||
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
||||
<Show when={versionLabel()}>
|
||||
<span class="selector-badge-version">v{versionLabel()}</span>
|
||||
<span class="selector-badge-version">{t("opencodeBinarySelector.versionLabel", { version: versionLabel() })}</span>
|
||||
</Show>
|
||||
<Show when={isPathValidating(binary.path)}>
|
||||
<span class="selector-badge-time">Checking…</span>
|
||||
<span class="selector-badge-time">{t("opencodeBinarySelector.status.checking")}</span>
|
||||
</Show>
|
||||
<Show when={!isDefault && binary.lastUsed}>
|
||||
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
||||
</Show>
|
||||
<Show when={isDefault}>
|
||||
<span class="selector-badge-time">Use binary from system PATH</span>
|
||||
<span class="selector-badge-time">{t("opencodeBinarySelector.badge.systemPath")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,7 +330,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
class="p-2 text-muted hover:text-primary"
|
||||
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
||||
disabled={props.disabled}
|
||||
title="Remove binary"
|
||||
title={t("opencodeBinarySelector.actions.removeTitle")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -343,8 +345,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
<FileSystemBrowserDialog
|
||||
open={isBinaryBrowserOpen()}
|
||||
mode="files"
|
||||
title="Select OpenCode Binary"
|
||||
description="Browse files exposed by the CLI server."
|
||||
title={t("opencodeBinarySelector.dialog.title")}
|
||||
description={t("opencodeBinarySelector.dialog.description")}
|
||||
onClose={() => setIsBinaryBrowserOpen(false)}
|
||||
onSelect={handleBinaryBrowserSelect}
|
||||
/>
|
||||
@@ -353,4 +355,3 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
}
|
||||
|
||||
export default OpenCodeBinarySelector
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Comp
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import {
|
||||
activeInterruption,
|
||||
getPermissionQueue,
|
||||
@@ -130,6 +131,7 @@ function resolveToolCallFromQuestion(instanceId: string, request: QuestionReques
|
||||
}
|
||||
|
||||
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
|
||||
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
|
||||
@@ -165,7 +167,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
const sessionId = getPermissionSessionId(permission) || ""
|
||||
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
|
||||
} catch (error) {
|
||||
setPermissionItemError(permissionId, error instanceof Error ? error.message : "Unable to update permission")
|
||||
setPermissionItemError(
|
||||
permissionId,
|
||||
error instanceof Error ? error.message : t("permissionApproval.errors.unableToUpdatePermission"),
|
||||
)
|
||||
} finally {
|
||||
setPermissionBusy(permissionId, false)
|
||||
}
|
||||
@@ -257,19 +262,24 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<div class="permission-center-modal-header">
|
||||
<div class="permission-center-modal-title-row">
|
||||
<h2 id="permission-center-title" class="permission-center-modal-title">
|
||||
Requests
|
||||
{t("permissionApproval.title")}
|
||||
</h2>
|
||||
<Show when={orderedQueue().length > 0}>
|
||||
<span class="permission-center-modal-count">{orderedQueue().length}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
|
||||
<button
|
||||
type="button"
|
||||
class="permission-center-modal-close"
|
||||
onClick={props.onClose}
|
||||
aria-label={t("permissionApproval.actions.closeAriaLabel")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="permission-center-modal-body">
|
||||
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
|
||||
<Show when={hasRequests()} fallback={<div class="permission-center-empty">{t("permissionApproval.empty")}</div>}>
|
||||
<div class="permission-center-list" role="list">
|
||||
<For each={orderedQueue()}>
|
||||
{(item) => {
|
||||
@@ -285,14 +295,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
|
||||
const showFallback = () => !resolved()
|
||||
|
||||
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
|
||||
const kindLabel = () =>
|
||||
item.kind === "permission"
|
||||
? t("permissionApproval.kind.permission")
|
||||
: t("permissionApproval.kind.question")
|
||||
|
||||
const primaryTitle = () => {
|
||||
if (item.kind === "permission") {
|
||||
return getPermissionDisplayTitle(item.payload)
|
||||
}
|
||||
const first = item.payload.questions?.[0]?.question
|
||||
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
|
||||
return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question")
|
||||
}
|
||||
|
||||
const secondaryTitle = () => {
|
||||
@@ -300,7 +313,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
return getPermissionKind(item.payload)
|
||||
}
|
||||
const count = item.payload.questions?.length ?? 0
|
||||
return count === 1 ? "1 question" : `${count} questions`
|
||||
return count === 1
|
||||
? t("permissionApproval.questionCount.one", { count })
|
||||
: t("permissionApproval.questionCount.other", { count })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -313,7 +328,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
|
||||
<span class="permission-center-item-kind">{secondaryTitle()}</span>
|
||||
<Show when={isActive()}>
|
||||
<span class="permission-center-item-chip">Active</span>
|
||||
<span class="permission-center-item-chip">{t("permissionApproval.status.active")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -326,7 +341,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
handleGoToSession(sessionId())
|
||||
}}
|
||||
>
|
||||
Go to Session
|
||||
{t("permissionApproval.actions.goToSession")}
|
||||
</button>
|
||||
<Show when={showFallback()}>
|
||||
<button
|
||||
@@ -338,7 +353,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
handleLoadSession(sessionId())
|
||||
}}
|
||||
>
|
||||
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
|
||||
{loadingSession() === sessionId()
|
||||
? t("permissionApproval.actions.loadingSession")
|
||||
: t("permissionApproval.actions.loadSession")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -360,7 +377,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
disabled={permissionSubmitting().has(item.id)}
|
||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
|
||||
>
|
||||
Allow Once
|
||||
{t("permissionApproval.actions.allowOnce")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -368,7 +385,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
disabled={permissionSubmitting().has(item.id)}
|
||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
|
||||
>
|
||||
Always Allow
|
||||
{t("permissionApproval.actions.alwaysAllow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -376,7 +393,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
disabled={permissionSubmitting().has(item.id)}
|
||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
|
||||
>
|
||||
Deny
|
||||
{t("permissionApproval.actions.deny")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,7 +402,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={item.kind !== "permission"}>
|
||||
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
||||
<div class="permission-center-fallback-hint">{t("permissionApproval.fallbackHint")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, createMemo, type Component } from "solid-js"
|
||||
import { ShieldAlert } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
|
||||
|
||||
interface PermissionNotificationBannerProps {
|
||||
@@ -8,17 +9,38 @@ interface PermissionNotificationBannerProps {
|
||||
}
|
||||
|
||||
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
|
||||
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
|
||||
const queueLength = createMemo(() => permissionCount() + questionCount())
|
||||
const hasRequests = createMemo(() => queueLength() > 0)
|
||||
const label = createMemo(() => {
|
||||
const total = queueLength()
|
||||
|
||||
const pendingLabel = total === 1
|
||||
? t("permissionBanner.pendingRequests.one", { count: total })
|
||||
: t("permissionBanner.pendingRequests.other", { count: total })
|
||||
|
||||
const parts: string[] = []
|
||||
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
|
||||
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
|
||||
const detail = parts.length ? ` (${parts.join(", ")})` : ""
|
||||
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
|
||||
|
||||
if (permissionCount() > 0) {
|
||||
parts.push(
|
||||
permissionCount() === 1
|
||||
? t("permissionBanner.detail.permission.one", { count: permissionCount() })
|
||||
: t("permissionBanner.detail.permission.other", { count: permissionCount() }),
|
||||
)
|
||||
}
|
||||
|
||||
if (questionCount() > 0) {
|
||||
parts.push(
|
||||
questionCount() === 1
|
||||
? t("permissionBanner.detail.question.one", { count: questionCount() })
|
||||
: t("permissionBanner.detail.question.other", { count: questionCount() }),
|
||||
)
|
||||
}
|
||||
|
||||
const detail = parts.length ? t("permissionBanner.detail.wrapper", { detail: parts.join(", ") }) : ""
|
||||
return `${pendingLabel}${detail}`
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
|
||||
import { getCommands } from "../stores/commands"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -32,6 +33,7 @@ interface PromptInputProps {
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const { t } = useI18n()
|
||||
const [prompt, setPromptInternal] = createSignal("")
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const HISTORY_LIMIT = 100
|
||||
@@ -53,9 +55,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (mode() === "shell") {
|
||||
return "Run a shell command (Esc to exit)..."
|
||||
return t("promptInput.placeholder.shell")
|
||||
}
|
||||
return "Type your message, @file, @agent, or paste images and text..."
|
||||
return t("promptInput.placeholder.default")
|
||||
}
|
||||
|
||||
|
||||
@@ -642,8 +644,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to send message:", error)
|
||||
showAlertDialog("Failed to send message", {
|
||||
title: "Send failed",
|
||||
showAlertDialog(t("promptInput.send.errorFallback"), {
|
||||
title: t("promptInput.send.errorTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
@@ -1019,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) {
|
||||
@@ -1048,8 +1050,11 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
|
||||
const commandHint = () => ({ key: "/", text: "Commands" })
|
||||
const shellHint = () =>
|
||||
mode() === "shell"
|
||||
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
|
||||
: { key: "!", text: t("promptInput.hints.shell.enable") }
|
||||
const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") })
|
||||
|
||||
const shouldShowOverlay = () => prompt().length === 0
|
||||
|
||||
@@ -1115,7 +1120,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectPreviousHistory(true)}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label="Previous prompt"
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1124,7 +1129,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectNextHistory(true)}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label="Next prompt"
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1137,10 +1142,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
fallback={
|
||||
<>
|
||||
<span class="prompt-overlay-text">
|
||||
<Kbd>Enter</Kbd> New line • <Kbd shortcut="cmd+enter" /> Send • <Kbd>@</Kbd> Files/agents • <Kbd>↑↓</Kbd> History
|
||||
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} • <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")} • <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} • <Kbd>↑↓</Kbd> {t("promptInput.overlay.history")}
|
||||
</span>
|
||||
<Show when={attachments().length > 0}>
|
||||
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
||||
<span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span>
|
||||
</Show>
|
||||
<span class="prompt-overlay-text">
|
||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||
@@ -1151,17 +1156,17 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={mode() === "shell"}>
|
||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<span class="prompt-overlay-text prompt-overlay-warning">
|
||||
Press <Kbd>Esc</Kbd> again to abort session
|
||||
{t("promptInput.overlay.press")} <Kbd>Esc</Kbd> {t("promptInput.overlay.againToAbort")}
|
||||
</span>
|
||||
<Show when={mode() === "shell"}>
|
||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
|
||||
</Show>
|
||||
</>
|
||||
</Show>
|
||||
@@ -1177,8 +1182,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
class="stop-button"
|
||||
onClick={handleAbort}
|
||||
disabled={!canStop()}
|
||||
aria-label="Stop session"
|
||||
title="Stop session"
|
||||
aria-label={t("promptInput.stopSession.ariaLabel")}
|
||||
title={t("promptInput.stopSession.title")}
|
||||
>
|
||||
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<rect x="4" y="4" width="12" height="12" rx="2" />
|
||||
@@ -1189,7 +1194,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
onClick={handleSend}
|
||||
disabled={!canSend()}
|
||||
aria-label="Send message"
|
||||
aria-label={t("promptInput.send.ariaLabel")}
|
||||
>
|
||||
<Show
|
||||
when={mode() === "shell"}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { restartCli } from "../lib/native/cli"
|
||||
import { preferences, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
@@ -18,6 +19,7 @@ interface RemoteAccessOverlayProps {
|
||||
}
|
||||
|
||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const { t } = useI18n()
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
@@ -85,11 +87,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", {
|
||||
title: allow ? "Open to other devices" : "Limit to this device",
|
||||
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||
variant: "warning",
|
||||
confirmLabel: "Restart now",
|
||||
cancelLabel: "Cancel",
|
||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
@@ -100,7 +102,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
setListeningMode(targetMode)
|
||||
const restarted = await restartCli()
|
||||
if (!restarted) {
|
||||
setError("Unable to restart automatically. Please restart the app to apply the change.")
|
||||
setError(t("remoteAccess.restart.errorManual"))
|
||||
} else {
|
||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||
}
|
||||
@@ -123,12 +125,12 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const confirm = passwordConfirm()
|
||||
|
||||
if (next.trim().length < 8) {
|
||||
setPasswordError("Password must be at least 8 characters.")
|
||||
setPasswordError(t("remoteAccess.password.error.tooShort"))
|
||||
return
|
||||
}
|
||||
|
||||
if (next !== confirm) {
|
||||
setPasswordError("Passwords do not match.")
|
||||
setPasswordError(t("remoteAccess.password.error.mismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,11 +164,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
|
||||
<header class="remote-header">
|
||||
<div>
|
||||
<p class="remote-eyebrow">Remote handover</p>
|
||||
<h2 class="remote-title">Connect to CodeNomad remotely</h2>
|
||||
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p>
|
||||
<p class="remote-eyebrow">{t("remoteAccess.eyebrow")}</p>
|
||||
<h2 class="remote-title">{t("remoteAccess.title")}</h2>
|
||||
<p class="remote-subtitle">{t("remoteAccess.subtitle")}</p>
|
||||
</div>
|
||||
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access">
|
||||
<button type="button" class="remote-close" onClick={props.onClose} aria-label={t("remoteAccess.close")}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
@@ -177,13 +179,13 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
<div class="remote-section-title">
|
||||
<Shield class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Listening mode</p>
|
||||
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p>
|
||||
<p class="remote-label">{t("remoteAccess.sections.listeningMode.label")}</p>
|
||||
<p class="remote-help">{t("remoteAccess.sections.listeningMode.help")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
|
||||
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
|
||||
<span class="remote-refresh-label">Refresh</span>
|
||||
<span class="remote-refresh-label">{t("remoteAccess.refresh")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -196,19 +198,18 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
>
|
||||
<Switch.Input />
|
||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||
<span class="remote-toggle-state">{allowExternalConnections() ? "On" : "Off"}</span>
|
||||
<span class="remote-toggle-state">{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}</span>
|
||||
<Switch.Thumb class="remote-toggle-thumb" />
|
||||
</Switch.Control>
|
||||
<div class="remote-toggle-copy">
|
||||
<span class="remote-toggle-title">Allow connections from other IPs</span>
|
||||
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
|
||||
<span class="remote-toggle-caption">
|
||||
{allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"}
|
||||
{allowExternalConnections() ? t("remoteAccess.toggle.caption.all") : t("remoteAccess.toggle.caption.local")}
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
<p class="remote-toggle-note">
|
||||
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the
|
||||
server restarts.
|
||||
{t("remoteAccess.toggle.note")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -217,22 +218,24 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
<div class="remote-section-title">
|
||||
<Shield class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Server password</p>
|
||||
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
|
||||
<p class="remote-label">{t("remoteAccess.sections.serverPassword.label")}</p>
|
||||
<p class="remote-help">{t("remoteAccess.sections.serverPassword.help")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={authStatus() && authStatus()!.authenticated}
|
||||
fallback={<div class="remote-card">Authentication status unavailable.</div>}
|
||||
fallback={<div class="remote-card">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||
>
|
||||
<div class="remote-card">
|
||||
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
|
||||
<p class="remote-help">
|
||||
{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}
|
||||
</p>
|
||||
<p class="remote-help">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? "A password is set for remote access."
|
||||
: "No memorable password is set yet. Set one to allow remote handover logins."}
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
|
||||
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||
@@ -245,26 +248,26 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? "Cancel"
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? "Change password"
|
||||
: "Set password"}
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={passwordFormOpen()}>
|
||||
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
|
||||
<label class="text-sm font-medium text-secondary">New password</label>
|
||||
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.newPassword")}</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
value={passwordValue()}
|
||||
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||
placeholder="At least 8 characters"
|
||||
placeholder={t("remoteAccess.password.form.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
|
||||
<label class="text-sm font-medium text-secondary">Confirm password</label>
|
||||
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.confirmPassword")}</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
@@ -284,7 +287,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
disabled={savingPassword()}
|
||||
onClick={() => void handleSubmitPassword()}
|
||||
>
|
||||
{savingPassword() ? "Saving…" : "Save password"}
|
||||
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -298,33 +301,39 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
<div class="remote-section-title">
|
||||
<Wifi class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Reachable addresses</p>
|
||||
<p class="remote-help">Launch or scan from another machine to hand over control.</p>
|
||||
<p class="remote-label">{t("remoteAccess.sections.addresses.label")}</p>
|
||||
<p class="remote-help">{t("remoteAccess.sections.addresses.help")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!loading()} fallback={<div class="remote-card">Loading addresses…</div>}>
|
||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">No addresses available yet.</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<div class="remote-address-list">
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
const expandedState = () => expandedUrl() === address.url
|
||||
const qr = () => qrCodes()[address.url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{address.url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} • {address.ip}
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
Open
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
@@ -333,14 +342,20 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? "Hide QR" : "Show QR"}
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => <img src={dataUrl()} alt={`QR for ${address.url}`} class="remote-qr-img" />}
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url: address.url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import {
|
||||
deleteSession,
|
||||
ensureSessionParentExpanded,
|
||||
@@ -34,23 +36,82 @@ interface SessionListProps {
|
||||
showFooter?: boolean
|
||||
headerContent?: JSX.Element
|
||||
footerContent?: JSX.Element
|
||||
enableFilterBar?: boolean
|
||||
}
|
||||
|
||||
function formatSessionStatus(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
case "working":
|
||||
return "Working"
|
||||
case "compacting":
|
||||
return "Compacting"
|
||||
default:
|
||||
return "Idle"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
const SessionList: Component<SessionListProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
|
||||
const [filterQuery, setFilterQuery] = createSignal("")
|
||||
const normalizedQuery = createMemo(() => filterQuery().trim().toLowerCase())
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const title = (session?.title ?? "").trim()
|
||||
return title || t("sessionList.session.untitled")
|
||||
}
|
||||
|
||||
const sessionMatchesQuery = (sessionId: string, query: string) => {
|
||||
if (!query) return true
|
||||
const label = normalizeSessionLabel(sessionId).toLowerCase()
|
||||
if (label.includes(query)) return true
|
||||
return sessionId.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
const filteredThreads = createMemo<SessionThread[]>(() => {
|
||||
const query = normalizedQuery()
|
||||
if (!query) return props.threads
|
||||
|
||||
const next: SessionThread[] = []
|
||||
for (const thread of props.threads) {
|
||||
const parentMatches = sessionMatchesQuery(thread.parent.id, query)
|
||||
const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query))
|
||||
|
||||
if (!parentMatches && matchingChildren.length === 0) continue
|
||||
|
||||
next.push({
|
||||
parent: thread.parent,
|
||||
children: matchingChildren,
|
||||
latestUpdated: thread.latestUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
const allMatchingSessionIds = createMemo<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
for (const thread of filteredThreads()) {
|
||||
ids.push(thread.parent.id)
|
||||
for (const child of thread.children) ids.push(child.id)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const selectedCount = createMemo(() => selectedSessionIds().size)
|
||||
|
||||
const isAllSelected = createMemo(() => {
|
||||
const ids = allMatchingSessionIds()
|
||||
if (ids.length === 0) return false
|
||||
const selected = selectedSessionIds()
|
||||
return ids.every((id) => selected.has(id))
|
||||
})
|
||||
const isSelectAllIndeterminate = createMemo(() => {
|
||||
const ids = allMatchingSessionIds()
|
||||
const total = ids.length
|
||||
if (total === 0) return false
|
||||
const count = selectedCount()
|
||||
return count > 0 && count < total
|
||||
})
|
||||
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instanceId)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
@@ -59,9 +120,10 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
// If the user selects a child session, make sure its parent thread is expanded.
|
||||
// For parent sessions we don't force expansion; user can collapse/expand freely.
|
||||
if (session?.parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, session.parentId)
|
||||
}
|
||||
|
||||
props.onSelect(sessionId)
|
||||
@@ -73,13 +135,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
try {
|
||||
const success = await copyToClipboard(sessionId)
|
||||
if (success) {
|
||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
||||
showToastNotification({ message: t("sessionList.copyId.success"), variant: "success" })
|
||||
} else {
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +149,17 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
t("sessionList.delete.confirmMessage", { label: normalizeSessionLabel(sessionId) }),
|
||||
{
|
||||
title: t("sessionList.delete.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||
},
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
const shouldSelectFallback = props.activeSessionId === sessionId
|
||||
let fallbackSessionId: string | undefined
|
||||
|
||||
@@ -127,7 +200,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to delete session", variant: "error" })
|
||||
showToastNotification({ message: t("sessionList.delete.error"), variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +225,120 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
setRenameTarget(null)
|
||||
} catch (error) {
|
||||
log.error(`Failed to rename session ${target.id}:`, error)
|
||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||
showToastNotification({ message: t("sessionList.rename.error"), variant: "error" })
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedMany = (sessionIds: string[], checked: boolean) => {
|
||||
if (sessionIds.length === 0) return
|
||||
setSelectedSessionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
sessionIds.forEach((id) => {
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getSelectableThreadIds = (parentId: string): string[] => {
|
||||
const query = normalizedQuery()
|
||||
const source = query ? filteredThreads() : props.threads
|
||||
const thread = source.find((t) => t.parent.id === parentId)
|
||||
if (!thread) return [parentId]
|
||||
return [thread.parent.id, ...thread.children.map((c) => c.id)]
|
||||
}
|
||||
|
||||
const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => {
|
||||
const ids: string[] = []
|
||||
threads.forEach((thread) => {
|
||||
ids.push(thread.parent.id)
|
||||
thread.children.forEach((child) => ids.push(child.id))
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
const handleToggleSelectAll = (checked: boolean) => {
|
||||
const ids = allMatchingSessionIds()
|
||||
setSelectedMany(ids, checked)
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected()) {
|
||||
handleToggleSelectAll(false)
|
||||
return
|
||||
}
|
||||
handleToggleSelectAll(true)
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const selected = Array.from(selectedSessionIds())
|
||||
if (selected.length === 0) return
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
t("sessionList.bulkDelete.confirmMessage", { count: selected.length }),
|
||||
{
|
||||
title: t("sessionList.bulkDelete.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||
},
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
const deletedSet = new Set(selected)
|
||||
const currentActiveId = props.activeSessionId
|
||||
|
||||
let fallbackSessionId: string | undefined
|
||||
if (currentActiveId && deletedSet.has(currentActiveId)) {
|
||||
const ordered = getAllSessionIdsInOrder(props.threads)
|
||||
const currentIndex = ordered.indexOf(currentActiveId)
|
||||
|
||||
for (let i = Math.max(0, currentIndex); i < ordered.length; i++) {
|
||||
const candidate = ordered[i]
|
||||
if (candidate && !deletedSet.has(candidate)) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!fallbackSessionId) {
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const candidate = ordered[i]
|
||||
if (candidate && !deletedSet.has(candidate)) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed = 0
|
||||
for (const sessionId of selected) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteSession(props.instanceId, sessionId)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedSessionIds(new Set<string>())
|
||||
|
||||
if (fallbackSessionId) {
|
||||
setActiveSessionFromList(props.instanceId, fallbackSessionId)
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
showToastNotification({
|
||||
message: t("sessionList.bulkDelete.error", { count: failed }),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const SessionRow: Component<{
|
||||
@@ -172,18 +354,54 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
return <></>
|
||||
}
|
||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||
const title = () => session()?.title || "Untitled"
|
||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const statusLabel = () => formatSessionStatus(status())
|
||||
const statusLabel = () => {
|
||||
switch (formatSessionStatus(status())) {
|
||||
case "working":
|
||||
return t("sessionList.status.working")
|
||||
case "compacting":
|
||||
return t("sessionList.status.compacting")
|
||||
default:
|
||||
return t("sessionList.status.idle")
|
||||
}
|
||||
}
|
||||
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||
const needsInput = () => needsPermission() || needsQuestion()
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel())
|
||||
const statusText = () =>
|
||||
needsPermission()
|
||||
? t("sessionList.status.needsPermission")
|
||||
: needsQuestion()
|
||||
? t("sessionList.status.needsInput")
|
||||
: statusLabel()
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||
|
||||
const parentGroupState = createMemo(() => {
|
||||
if (rowProps.isChild) {
|
||||
return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] }
|
||||
}
|
||||
|
||||
const ids = getSelectableThreadIds(rowProps.sessionId)
|
||||
const selected = selectedSessionIds()
|
||||
const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0)
|
||||
return {
|
||||
checked: selectedInGroup > 0 && selectedInGroup === ids.length,
|
||||
indeterminate: selectedInGroup > 0 && selectedInGroup < ids.length,
|
||||
ids,
|
||||
}
|
||||
})
|
||||
|
||||
let rowCheckboxEl: HTMLInputElement | null = null
|
||||
createEffect(() => {
|
||||
if (!rowCheckboxEl) return
|
||||
rowCheckboxEl.indeterminate = parentGroupState().indeterminate
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
<button
|
||||
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
data-session-id={rowProps.sessionId}
|
||||
@@ -195,11 +413,23 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
{rowProps.isChild ? (
|
||||
<Bot class="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<User class="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<Show when={props.enableFilterBar}>
|
||||
<input
|
||||
ref={(el) => {
|
||||
rowCheckboxEl = el
|
||||
}}
|
||||
type="checkbox"
|
||||
checked={parentGroupState().checked}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
setSelectedMany(parentGroupState().ids, event.currentTarget.checked)
|
||||
}}
|
||||
aria-label={t("sessionList.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
||||
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,9 +437,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={rowProps.hasChildren && !rowProps.isChild}
|
||||
fallback={
|
||||
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
|
||||
}
|
||||
fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
|
||||
>
|
||||
<span
|
||||
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
@@ -219,18 +447,16 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"}
|
||||
title={rowProps.expanded ? "Collapse" : "Expand"}
|
||||
aria-label={
|
||||
rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")
|
||||
}
|
||||
title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
|
||||
>
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{needsInput() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{statusText()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -240,8 +466,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
onClick={(event) => copySessionId(event, rowProps.sessionId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Copy session ID"
|
||||
title="Copy session ID"
|
||||
aria-label={t("sessionList.actions.copyId.ariaLabel")}
|
||||
title={t("sessionList.actions.copyId.title")}
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</span>
|
||||
@@ -253,8 +479,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Rename session"
|
||||
title="Rename session"
|
||||
aria-label={t("sessionList.actions.rename.ariaLabel")}
|
||||
title={t("sessionList.actions.rename.title")}
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
</span>
|
||||
@@ -263,8 +489,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Delete session"
|
||||
title="Delete session"
|
||||
aria-label={t("sessionList.actions.delete.ariaLabel")}
|
||||
title={t("sessionList.actions.delete.title")}
|
||||
>
|
||||
<Show
|
||||
when={!isSessionDeleting(rowProps.sessionId)}
|
||||
@@ -300,6 +526,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Keep the active child session visible by ensuring its parent is expanded.
|
||||
// Don't force-expanding when the active session itself is a parent lets users collapse it.
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return
|
||||
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||
if (!activeSession) return
|
||||
if (!activeSession.parentId) return
|
||||
const parentId = activeParentId()
|
||||
if (!parentId) return
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
@@ -356,11 +589,68 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||
>
|
||||
<Show when={props.enableFilterBar}>
|
||||
<div class="p-3 border-b border-base">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true">
|
||||
<Search class="w-4 h-4" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input pl-9"
|
||||
value={filterQuery()}
|
||||
onInput={(e) => setFilterQuery(e.currentTarget.value)}
|
||||
placeholder={t("sessionList.filter.placeholder")}
|
||||
aria-label={t("sessionList.filter.ariaLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary p-2 inline-flex items-center justify-center"
|
||||
onClick={toggleSelectAll}
|
||||
disabled={allMatchingSessionIds().length === 0}
|
||||
aria-label={t("sessionList.selection.selectAllAriaLabel")}
|
||||
title={t("sessionList.selection.selectAllLabel")}
|
||||
>
|
||||
<Show
|
||||
when={isSelectAllIndeterminate()}
|
||||
fallback={isAllSelected() ? <CheckSquare class="w-4 h-4" /> : <Square class="w-4 h-4" />}
|
||||
>
|
||||
<MinusSquare class="w-4 h-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={selectedCount() > 0}>
|
||||
<div class="mt-2 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={handleBulkDelete}
|
||||
aria-label={t("sessionList.bulkDelete.ariaLabel", { count: selectedCount() })}
|
||||
>
|
||||
{t("sessionList.bulkDelete.button", { count: selectedCount() })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={() => setSelectedSessionIds(new Set<string>())}
|
||||
aria-label={t("sessionList.selection.clearAriaLabel")}
|
||||
>
|
||||
{t("sessionList.selection.clearLabel")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showHeader !== false}>
|
||||
<div class="session-list-header p-3 border-b border-base">
|
||||
{props.headerContent ?? (
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-primary">Sessions</h3>
|
||||
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
||||
/>
|
||||
@@ -369,33 +659,33 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
|
||||
<Show when={props.threads.length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={props.threads}>
|
||||
<Show when={filteredThreads().length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={filteredThreads()}>
|
||||
|
||||
{(thread) => {
|
||||
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
{(thread) => {
|
||||
const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -420,4 +710,3 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}
|
||||
|
||||
export default SessionList
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getParentSessions, createSession, setActiveParentSession } from "../sto
|
||||
import { instances, stopInstance } from "../stores/instances"
|
||||
import { agents } from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -15,6 +16,7 @@ interface SessionPickerProps {
|
||||
}
|
||||
|
||||
const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
||||
const [isCreating, setIsCreating] = createSignal(false)
|
||||
|
||||
@@ -40,10 +42,10 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||
return t("time.relative.justNow")
|
||||
}
|
||||
|
||||
async function handleSessionSelect(sessionId: string) {
|
||||
@@ -74,19 +76,19 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6">
|
||||
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
|
||||
OpenCode • {instance()?.folder.split("/").pop()}
|
||||
</Dialog.Title>
|
||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6">
|
||||
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
|
||||
{t("sessionPicker.title", { folder: instance()?.folder.split("/").pop() })}
|
||||
</Dialog.Title>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Show
|
||||
when={parentSessions().length > 0}
|
||||
fallback={<div class="text-center py-4 text-sm text-muted">No previous sessions</div>}
|
||||
fallback={<div class="text-center py-4 text-sm text-muted">{t("sessionPicker.empty.noPrevious")}</div>}
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-secondary mb-2">
|
||||
Resume a session ({parentSessions().length}):
|
||||
{t("sessionPicker.resume.title", { count: parentSessions().length })}
|
||||
</h3>
|
||||
<div class="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
<For each={parentSessions()}>
|
||||
@@ -98,7 +100,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
>
|
||||
<div class="selector-option-content w-full">
|
||||
<span class="selector-option-label truncate">
|
||||
{session.title || "Untitled"}
|
||||
{session.title || t("sessionPicker.session.untitled")}
|
||||
</span>
|
||||
</div>
|
||||
<span class="selector-badge-time flex-shrink-0">
|
||||
@@ -116,16 +118,16 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
<div class="w-full border-t border-base" />
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-surface-base text-muted">or</span>
|
||||
<span class="px-2 bg-surface-base text-muted">{t("sessionPicker.divider.or")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-secondary mb-2">Start new session:</h3>
|
||||
<h3 class="text-sm font-medium text-secondary mb-2">{t("sessionPicker.new.title")}</h3>
|
||||
<div class="space-y-3">
|
||||
<Show
|
||||
when={agentList().length > 0}
|
||||
fallback={<div class="text-sm text-muted">Loading agents...</div>}
|
||||
fallback={<div class="text-sm text-muted">{t("sessionPicker.agents.loading")}</div>}
|
||||
>
|
||||
<select
|
||||
class="selector-input w-full"
|
||||
@@ -161,9 +163,13 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
</Show>
|
||||
<Show
|
||||
when={!isCreating()}
|
||||
fallback={<span>Creating...</span>}
|
||||
fallback={<span>{t("sessionPicker.actions.creating")}</span>}
|
||||
>
|
||||
<span>{agentList().length === 0 ? "Loading agents..." : "Create Session"}</span>
|
||||
<span>
|
||||
{agentList().length === 0
|
||||
? t("sessionPicker.agents.loading")
|
||||
: t("sessionPicker.actions.createSession")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<kbd class="kbd ml-2">
|
||||
@@ -180,7 +186,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
{t("sessionPicker.actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface SessionRenameDialogProps {
|
||||
open: boolean
|
||||
@@ -11,6 +12,7 @@ interface SessionRenameDialogProps {
|
||||
}
|
||||
|
||||
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [title, setTitle] = createSignal("")
|
||||
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
@@ -40,9 +42,9 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
|
||||
const description = () => {
|
||||
if (props.sessionLabel && props.sessionLabel.trim()) {
|
||||
return `Update the title for "${props.sessionLabel}".`
|
||||
return t("sessionRenameDialog.description.withLabel", { label: props.sessionLabel })
|
||||
}
|
||||
return "Set a new title for this session."
|
||||
return t("sessionRenameDialog.description.default")
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -58,7 +60,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title>
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{t("sessionRenameDialog.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||
{description()}
|
||||
</Dialog.Description>
|
||||
@@ -66,7 +68,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-secondary" for={inputId}>
|
||||
Session name
|
||||
{t("sessionRenameDialog.input.label")}
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
@@ -76,7 +78,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||
placeholder="Enter a session name"
|
||||
placeholder={t("sessionRenameDialog.input.placeholder")}
|
||||
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
|
||||
/>
|
||||
</div>
|
||||
@@ -92,7 +94,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
}}
|
||||
disabled={isSubmitting()}
|
||||
>
|
||||
Cancel
|
||||
{t("sessionRenameDialog.actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -111,11 +113,11 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Renaming…</span>
|
||||
<span>{t("sessionRenameDialog.actions.renaming")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Rename
|
||||
{t("sessionRenameDialog.actions.rename")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { getSessionInfo } from "../../stores/sessions"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
interface ContextUsagePanelProps {
|
||||
instanceId: string
|
||||
@@ -8,10 +9,11 @@ interface ContextUsagePanelProps {
|
||||
}
|
||||
|
||||
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
|
||||
const headingClass = "text-xs font-semibold text-muted uppercase tracking-wide"
|
||||
|
||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const info = createMemo(
|
||||
() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
@@ -39,7 +41,7 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
|
||||
|
||||
const formatTokenValue = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return "--"
|
||||
if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
|
||||
return formatTokenTotal(value)
|
||||
}
|
||||
|
||||
@@ -47,30 +49,30 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>Tokens</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
||||
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Input</span>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Output</span>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.output")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Cost</span>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.cost")}</span>
|
||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>Context</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
||||
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Used</span>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Avail</span>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.available")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
@@ -9,11 +9,12 @@ import PromptInput from "../prompt-input"
|
||||
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -34,6 +35,7 @@ interface SessionViewProps {
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
@@ -110,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(() => {
|
||||
@@ -152,8 +191,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
||||
} catch (error) {
|
||||
log.error("Failed to abort session", error)
|
||||
showAlertDialog("Failed to stop session", {
|
||||
title: "Stop failed",
|
||||
showAlertDialog(t("sessionView.alerts.abortFailed.message"), {
|
||||
title: t("sessionView.alerts.abortFailed.title"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
@@ -201,8 +240,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to revert message", error)
|
||||
showAlertDialog("Failed to revert to message", {
|
||||
title: "Revert failed",
|
||||
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
|
||||
title: t("sessionView.alerts.revertFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
@@ -215,10 +254,15 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
const parentTitle = (session()?.title ?? "").trim() || t("sessionList.session.untitled")
|
||||
|
||||
try {
|
||||
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
|
||||
|
||||
renameSession(props.instanceId, forkedSession.id, `Fork: ${parentTitle}`).catch((error) => {
|
||||
log.error("Failed to rename forked session", error)
|
||||
})
|
||||
|
||||
const parentToActivate = forkedSession.parentId ?? forkedSession.id
|
||||
setActiveParentSession(props.instanceId, parentToActivate)
|
||||
if (forkedSession.parentId) {
|
||||
@@ -237,8 +281,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fork session", error)
|
||||
showAlertDialog("Failed to fork session", {
|
||||
title: "Fork failed",
|
||||
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
|
||||
title: t("sessionView.alerts.forkFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
@@ -250,7 +294,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
when={session()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500">Session not found</div>
|
||||
<div class="text-center text-gray-500">{t("sessionView.fallback.sessionNotFound")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -296,8 +340,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
type="button"
|
||||
class="attachment-expand"
|
||||
onClick={() => handleExpandTextAttachment(attachment)}
|
||||
aria-label="Expand pasted text"
|
||||
title="Insert pasted text"
|
||||
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||
>
|
||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -306,7 +350,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
type="button"
|
||||
class="attachment-remove"
|
||||
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||
aria-label="Remove attachment"
|
||||
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
39
packages/ui/src/components/theme-mode-toggle.tsx
Normal file
39
packages/ui/src/components/theme-mode-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { Laptop, Moon, Sun } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { useTheme } from "../lib/theme"
|
||||
|
||||
interface ThemeModeToggleProps {
|
||||
class?: string
|
||||
}
|
||||
|
||||
export const ThemeModeToggle: Component<ThemeModeToggleProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { themeMode, cycleThemeMode } = useTheme()
|
||||
|
||||
const modeLabel = () => {
|
||||
const mode = themeMode()
|
||||
if (mode === "system") return t("theme.mode.system")
|
||||
if (mode === "light") return t("theme.mode.light")
|
||||
return t("theme.mode.dark")
|
||||
}
|
||||
|
||||
const icon = createMemo(() => {
|
||||
const mode = themeMode()
|
||||
if (mode === "system") return <Laptop class="w-4 h-4" />
|
||||
if (mode === "light") return <Sun class="w-4 h-4" />
|
||||
return <Moon class="w-4 h-4" />
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={props.class ?? "new-tab-button"}
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={t("theme.toggle.ariaLabel", { mode: modeLabel() })}
|
||||
title={t("theme.toggle.title", { mode: modeLabel() })}
|
||||
>
|
||||
{icon()}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
109
packages/ui/src/components/thinking-selector.tsx
Normal file
109
packages/ui/src/components/thinking-selector.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface ThinkingSelectorProps {
|
||||
instanceId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
}
|
||||
|
||||
type ThinkingOption = {
|
||||
key: string
|
||||
label: string
|
||||
value: string | undefined
|
||||
}
|
||||
|
||||
export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
||||
const { t } = useI18n()
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
|
||||
}
|
||||
})
|
||||
|
||||
const variantKeys = createMemo(() => {
|
||||
const { providerId, modelId } = props.currentModel
|
||||
const provider = instanceProviders().find((p) => p.id === providerId)
|
||||
const model = provider?.models.find((m) => m.id === modelId)
|
||||
return model?.variantKeys ?? []
|
||||
})
|
||||
|
||||
const options = createMemo<ThinkingOption[]>(() => {
|
||||
const keys = variantKeys()
|
||||
return [
|
||||
{ key: "__default__", label: t("thinkingSelector.variant.default"), value: undefined },
|
||||
...keys.map((k) => ({ key: k, label: k, value: k })),
|
||||
]
|
||||
})
|
||||
|
||||
const currentValue = createMemo(() => {
|
||||
const selected = getModelThinkingSelection(props.currentModel)
|
||||
const keys = variantKeys()
|
||||
if (selected && keys.includes(selected)) {
|
||||
return options().find((opt) => opt.value === selected)
|
||||
}
|
||||
return options()[0]
|
||||
})
|
||||
|
||||
const handleChange = (value: ThinkingOption | null) => {
|
||||
if (!value) return
|
||||
setModelThinkingSelection(props.currentModel, value.value)
|
||||
}
|
||||
|
||||
const triggerPrimary = createMemo(() => {
|
||||
const selected = currentValue()?.value
|
||||
const variant = selected ?? t("thinkingSelector.variant.default")
|
||||
return t("thinkingSelector.label", { variant })
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
<Combobox<ThinkingOption>
|
||||
value={currentValue()}
|
||||
onChange={handleChange}
|
||||
options={options()}
|
||||
optionValue="key"
|
||||
optionLabel="label"
|
||||
placeholder={t("thinkingSelector.label", { variant: t("thinkingSelector.variant.default") })}
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item item={itemProps.item} class="selector-option">
|
||||
<div class="selector-option-content">
|
||||
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Combobox.ItemLabel>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-thinking-selector-control>
|
||||
<Combobox.Input class="sr-only" data-thinking-selector />
|
||||
<Combobox.Trigger class="selector-trigger">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
|
||||
</div>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</Combobox.Control>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="selector-popover">
|
||||
<Combobox.Listbox class="selector-listbox" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
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"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||
import { QuestionToolBlock } from "./tool-call/question-block"
|
||||
import { PermissionToolBlock } from "./tool-call/permission-block"
|
||||
@@ -58,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
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +75,7 @@ interface ToolCallProps {
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const toolCallMemo = createMemo(() => props.toolCall)
|
||||
const toolName = createMemo(() => toolCallMemo()?.tool || "")
|
||||
const toolCallIdentifier = createMemo(() => {
|
||||
@@ -140,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") {
|
||||
@@ -233,12 +245,16 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(autoScroll())
|
||||
if (!expanded()) return
|
||||
scheduleAnchorScroll()
|
||||
scheduleAnchorScroll(true)
|
||||
})
|
||||
}
|
||||
|
||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||
scrollContainerRef = element || undefined
|
||||
const next = element || undefined
|
||||
if (next === scrollContainerRef) {
|
||||
return
|
||||
}
|
||||
scrollContainerRef = next
|
||||
setScrollContainer(scrollContainerRef)
|
||||
if (scrollContainerRef) {
|
||||
restoreScrollPosition(autoScroll())
|
||||
@@ -442,7 +458,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return row.map((value) => value.trim()).filter((value) => value.length > 0)
|
||||
})
|
||||
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
|
||||
setQuestionError("Please answer all questions before submitting.")
|
||||
setQuestionError(t("toolCall.question.validation.answerAll"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -453,7 +469,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
|
||||
} catch (error) {
|
||||
log.error("Failed to send question reply", error)
|
||||
setQuestionError(error instanceof Error ? error.message : "Unable to reply")
|
||||
setQuestionError(error instanceof Error ? error.message : t("toolCall.question.errors.unableToReply"))
|
||||
} finally {
|
||||
setQuestionSubmitting(false)
|
||||
}
|
||||
@@ -471,7 +487,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
await sendQuestionReject(props.instanceId, sessionId, request.id)
|
||||
} catch (error) {
|
||||
log.error("Failed to reject question", error)
|
||||
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss")
|
||||
setQuestionError(error instanceof Error ? error.message : t("toolCall.question.errors.unableToDismiss"))
|
||||
} finally {
|
||||
setQuestionSubmitting(false)
|
||||
}
|
||||
@@ -545,6 +561,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
preferences,
|
||||
setDiffViewMode,
|
||||
isDark,
|
||||
t,
|
||||
diffCache,
|
||||
permissionDiffCache,
|
||||
scrollHelpers,
|
||||
@@ -568,11 +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,
|
||||
}
|
||||
|
||||
@@ -589,7 +624,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return
|
||||
}
|
||||
previousPartVersion = version
|
||||
scheduleAnchorScroll()
|
||||
scheduleAnchorScroll(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -626,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)
|
||||
}
|
||||
@@ -639,7 +687,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
|
||||
} catch (error) {
|
||||
log.error("Failed to send permission response", error)
|
||||
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
|
||||
setPermissionError(error instanceof Error ? error.message : t("toolCall.permission.errors.unableToUpdate"))
|
||||
} finally {
|
||||
setPermissionSubmitting(false)
|
||||
}
|
||||
@@ -651,7 +699,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
if (state.status === "error" && state.error) {
|
||||
return (
|
||||
<div class="tool-call-error-content">
|
||||
<strong>Error:</strong> {state.error}
|
||||
<strong>{t("toolCall.error.label")}</strong> {state.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -729,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">
|
||||
@@ -752,7 +816,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<Show when={status() === "pending" && !pendingPermission()}>
|
||||
<div class="tool-call-pending-message">
|
||||
<span class="spinner-small"></span>
|
||||
<span>Waiting to run...</span>
|
||||
<span>{t("toolCall.pending.waitingToRun")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -761,6 +825,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<Show when={diagnosticsEntries().length}>
|
||||
|
||||
{renderDiagnosticsSection(
|
||||
t,
|
||||
diagnosticsEntries(),
|
||||
diagnosticsExpanded(),
|
||||
() => setDiagnosticsOverride((prev) => {
|
||||
|
||||
@@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
|
||||
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||
{params.scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show } from "solid-js"
|
||||
import type { DiagnosticEntry } from "./diagnostics"
|
||||
|
||||
export function renderDiagnosticsSection(
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
entries: DiagnosticEntry[],
|
||||
expanded: boolean,
|
||||
toggle: () => void,
|
||||
@@ -22,13 +23,13 @@ export function renderDiagnosticsSection(
|
||||
<span class="tool-call-emoji" aria-hidden="true">
|
||||
🛠
|
||||
</span>
|
||||
<span class="tool-call-summary">Diagnostics</span>
|
||||
<span class="tool-call-summary">{t("toolCall.diagnostics.title")}</span>
|
||||
<span class="tool-call-diagnostics-file" title={fileLabel}>
|
||||
{fileLabel}
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded}>
|
||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||
<div class="tool-call-diagnostics" role="region" aria-label={t("toolCall.diagnostics.ariaLabel")}>
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
||||
import { tGlobal } from "../../lib/i18n"
|
||||
|
||||
interface LspRangePosition {
|
||||
line?: number
|
||||
@@ -40,9 +41,9 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
if (tone === "error") return { label: tGlobal("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: tGlobal("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
||||
return { label: tGlobal("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||
|
||||
@@ -19,19 +19,32 @@ export function createDiffContentRenderer(params: {
|
||||
preferences: Accessor<DiffPrefs>
|
||||
setDiffViewMode: (mode: DiffViewMode) => void
|
||||
isDark: Accessor<boolean>
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
diffCache: CacheHandle
|
||||
permissionDiffCache: CacheHandle
|
||||
scrollHelpers: ToolScrollHelpers
|
||||
handleScrollRendered: () => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
const registerTracked = (element: HTMLDivElement | null) => {
|
||||
params.scrollHelpers.registerContainer(element)
|
||||
}
|
||||
|
||||
const registerUntracked = (element: HTMLDivElement | null) => {
|
||||
params.scrollHelpers.registerContainer(element, { disableTracking: true })
|
||||
}
|
||||
|
||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const toolbarLabel = options?.label || (relativePath
|
||||
? params.t("toolCall.diff.label.withPath", { path: relativePath })
|
||||
: params.t("toolCall.diff.label"))
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = params.isDark() ? "dark" : "light"
|
||||
const disableScrollTracking = Boolean(options?.disableScrollTracking)
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
|
||||
const baseEntryParams = cacheHandle.params() as any
|
||||
const cacheEntryParams = (() => {
|
||||
@@ -55,7 +68,7 @@ export function createDiffContentRenderer(params: {
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (!options?.disableScrollTracking) {
|
||||
if (!disableScrollTracking) {
|
||||
params.handleScrollRendered()
|
||||
}
|
||||
params.onContentRendered?.()
|
||||
@@ -64,10 +77,10 @@ export function createDiffContentRenderer(params: {
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
ref={registerRef}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
|
||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<button
|
||||
@@ -76,7 +89,7 @@ export function createDiffContentRenderer(params: {
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
>
|
||||
Split
|
||||
{params.t("toolCall.diff.viewMode.split")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -84,7 +97,7 @@ export function createDiffContentRenderer(params: {
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
{params.t("toolCall.diff.viewMode.unified")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +110,7 @@ export function createDiffContentRenderer(params: {
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ export function createMarkdownContentRenderer(params: {
|
||||
handleScrollRendered: () => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
const registerTracked = (element: HTMLDivElement | null) => {
|
||||
params.scrollHelpers.registerContainer(element)
|
||||
}
|
||||
|
||||
const registerUntracked = (element: HTMLDivElement | null) => {
|
||||
params.scrollHelpers.registerContainer(element, { disableTracking: true })
|
||||
}
|
||||
|
||||
function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null {
|
||||
if (!options.content) {
|
||||
return null
|
||||
@@ -24,6 +32,7 @@ export function createMarkdownContentRenderer(params: {
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const disableScrollTracking = options.disableScrollTracking || false
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
|
||||
const state = params.toolState()
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
@@ -31,7 +40,7 @@ export function createMarkdownContentRenderer(params: {
|
||||
return (
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||
ref={registerRef}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
@@ -56,7 +65,7 @@ export function createMarkdownContentRenderer(params: {
|
||||
return (
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||
ref={registerRef}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<Markdown
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Show, type Accessor, type JSXElement } from "solid-js"
|
||||
import type { PermissionRequestLike } from "../../types/permission"
|
||||
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
|
||||
import { getPermissionSessionId } from "../../types/permission"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import type { DiffPayload, DiffRenderOptions } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
|
||||
@@ -18,6 +19,8 @@ export type PermissionToolBlockProps = {
|
||||
}
|
||||
|
||||
export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const diffPayload = () => {
|
||||
const permission = props.permission()
|
||||
if (!permission) return null
|
||||
@@ -48,7 +51,9 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
{(permission) => (
|
||||
<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() ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-label">
|
||||
{props.active() ? t("toolCall.permission.status.required") : t("toolCall.permission.status.queued")}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">{getPermissionKind(permission())}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
@@ -62,14 +67,14 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath
|
||||
? `Requested diff · ${getRelativePath(payload().filePath || "")}`
|
||||
: "Requested diff",
|
||||
? t("toolCall.permission.requestedDiff.withPath", { path: getRelativePath(payload().filePath || "") })
|
||||
: t("toolCall.permission.requestedDiff.label"),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!props.active()}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
|
||||
<p class="tool-call-permission-queued-text">{t("toolCall.permission.queuedText")}</p>
|
||||
</Show>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
@@ -79,7 +84,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("once")}
|
||||
>
|
||||
Allow Once
|
||||
{t("toolCall.permission.actions.allowOnce")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,7 +92,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("always")}
|
||||
>
|
||||
Always Allow
|
||||
{t("toolCall.permission.actions.alwaysAllow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -95,17 +100,17 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("reject")}
|
||||
>
|
||||
Deny
|
||||
{t("toolCall.permission.actions.deny")}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Allow once</span>
|
||||
<span>{t("toolCall.permission.shortcuts.allowOnce")}</span>
|
||||
<kbd class="kbd">A</kbd>
|
||||
<span>Always allow</span>
|
||||
<span>{t("toolCall.permission.shortcuts.alwaysAllow")}</span>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<span>Deny</span>
|
||||
<span>{t("toolCall.permission.shortcuts.deny")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createMemo, Show, For, type Accessor } from "solid-js"
|
||||
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
type QuestionOption = { label: string; description: string }
|
||||
|
||||
@@ -26,6 +27,15 @@ export type QuestionToolBlockProps = {
|
||||
}
|
||||
|
||||
export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
const { t } = useI18n()
|
||||
let firstInputRef: HTMLInputElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (props.active() && firstInputRef) {
|
||||
firstInputRef.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const requestId = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
const request = props.request()
|
||||
@@ -70,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) => {
|
||||
@@ -160,16 +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() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</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()
|
||||
@@ -183,21 +201,21 @@ 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">
|
||||
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||
<div class="text-sm text-primary">
|
||||
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
<div class="text-xs text-muted">Multiple</div>
|
||||
<div class="text-xs text-muted">{t("toolCall.question.multiple")}</div>
|
||||
</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 ?? []}>
|
||||
{(opt) => {
|
||||
{(opt, optIndex) => {
|
||||
const checked = () => selected().includes(opt.label)
|
||||
return (
|
||||
<label
|
||||
@@ -205,14 +223,18 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
title={opt.description}
|
||||
>
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (i() === 0 && optIndex() === 0) firstInputRef = el
|
||||
}}
|
||||
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>
|
||||
@@ -222,11 +244,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
|
||||
<label
|
||||
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||
title="Type a custom answer"
|
||||
title={t("toolCall.question.custom.title")}
|
||||
>
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (i() === 0 && (q?.options?.length ?? 0) === 0) firstInputRef = el
|
||||
}}
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||
checked={customChecked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={(e) => {
|
||||
@@ -244,19 +270,26 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="text-sm leading-tight">Custom answer</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="Type your own answer"
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
placeholder={t("toolCall.question.custom.placeholder")}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
onFocus={(e) => {
|
||||
if (!props.active()) return
|
||||
// Keep the radio/checkbox selected while editing.
|
||||
toggleFromCustomInput(i(), e.currentTarget)
|
||||
}}
|
||||
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
if (!submitDisabled()) {
|
||||
props.onSubmit()
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
@@ -267,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"
|
||||
@@ -275,7 +308,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
disabled={submitDisabled()}
|
||||
onClick={() => props.onSubmit()}
|
||||
>
|
||||
Submit
|
||||
{t("toolCall.question.actions.submit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -283,15 +316,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
disabled={props.submitting()}
|
||||
onClick={() => props.onDismiss()}
|
||||
>
|
||||
Dismiss
|
||||
{t("toolCall.question.actions.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Submit</span>
|
||||
<span>{t("toolCall.question.shortcuts.submit")}</span>
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Dismiss</span>
|
||||
<span>{t("toolCall.question.shortcuts.dismiss")}</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.error()}>
|
||||
@@ -301,7 +334,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={!props.active() && props.request()}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||
<p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,10 +35,10 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
|
||||
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
||||
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function resolveDiagnosticsKey(
|
||||
@@ -69,6 +69,7 @@ function resolveDiagnosticsKey(
|
||||
function buildDiagnostics(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): DiagnosticEntry[] {
|
||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
||||
if (!key) return []
|
||||
@@ -82,7 +83,7 @@ function buildDiagnostics(
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const severityMeta = getSeverityMeta(tone, t)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
|
||||
@@ -103,11 +104,14 @@ function buildDiagnostics(
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string }) {
|
||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
|
||||
return (
|
||||
<Show when={props.entries.length > 0}>
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<div class="tool-call-diagnostics" role="region" aria-label={`Diagnostics ${props.label}`}
|
||||
<div
|
||||
class="tool-call-diagnostics"
|
||||
role="region"
|
||||
aria-label={props.t("toolCall.diagnostics.ariaLabel.withLabel", { label: props.label })}
|
||||
>
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={props.entries}>
|
||||
@@ -134,19 +138,22 @@ function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string })
|
||||
|
||||
export const applyPatchRenderer: ToolRenderer = {
|
||||
tools: ["apply_patch"],
|
||||
getAction: () => "Preparing apply_patch...",
|
||||
getTitle({ toolState }) {
|
||||
getAction: ({ t }) => t("toolCall.applyPatch.action.preparing"),
|
||||
getTitle({ toolState, t }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
if (state.status === "pending") return getToolName("apply_patch")
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
|
||||
if (files.length > 0) {
|
||||
return `${getToolName("apply_patch")} (${files.length} file${files.length === 1 ? "" : "s"})`
|
||||
const tool = getToolName("apply_patch")
|
||||
return files.length === 1
|
||||
? t("toolCall.applyPatch.title.withFileCount.one", { tool, count: files.length })
|
||||
: t("toolCall.applyPatch.title.withFileCount.other", { tool, count: files.length })
|
||||
}
|
||||
return getToolName("apply_patch")
|
||||
},
|
||||
renderBody({ toolState, renderDiff, renderMarkdown }) {
|
||||
renderBody({ toolState, renderDiff, renderMarkdown, t }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
@@ -170,10 +177,10 @@ export const applyPatchRenderer: ToolRenderer = {
|
||||
<div class="tool-call-apply-patch">
|
||||
<For each={files()}>
|
||||
{(file, index) => {
|
||||
const labelBase = file.relativePath || file.filePath || `File ${index() + 1}`
|
||||
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
|
||||
const diffText = typeof file.diff === "string" ? file.diff : ""
|
||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file))
|
||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
|
||||
|
||||
return (
|
||||
<div class="tool-call-apply-patch-file">
|
||||
@@ -181,12 +188,12 @@ export const applyPatchRenderer: ToolRenderer = {
|
||||
{renderDiff(
|
||||
{ diffText, filePath },
|
||||
{
|
||||
label: `Diff · ${getRelativePath(labelBase)}`,
|
||||
label: t("toolCall.diff.label.withPath", { path: getRelativePath(labelBase) }),
|
||||
cacheKey: `apply_patch:${labelBase}:${index()}`,
|
||||
},
|
||||
)}
|
||||
</Show>
|
||||
<DiagnosticsInline entries={entries()} label={labelBase} />
|
||||
<DiagnosticsInline entries={entries()} label={labelBase} t={t} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
|
||||
export const bashRenderer: ToolRenderer = {
|
||||
tools: ["bash"],
|
||||
getAction: () => "Writing command...",
|
||||
getAction: () => tGlobal("toolCall.renderer.action.writingCommand"),
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
@@ -18,7 +19,7 @@ export const bashRenderer: ToolRenderer = {
|
||||
}
|
||||
|
||||
const timeoutLabel = `${timeout}ms`
|
||||
return `${baseTitle} · Timeout: ${timeoutLabel}`
|
||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
||||
const state = toolState()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
|
||||
export const editRenderer: ToolRenderer = {
|
||||
tools: ["edit"],
|
||||
getAction: () => "Preparing edit...",
|
||||
getAction: () => tGlobal("toolCall.renderer.action.preparingEdit"),
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
|
||||
export const patchRenderer: ToolRenderer = {
|
||||
tools: ["patch"],
|
||||
getAction: () => "Preparing patch...",
|
||||
getAction: () => tGlobal("toolCall.renderer.action.preparingPatch"),
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { ToolRenderer } from "../types"
|
||||
|
||||
export const questionRenderer: ToolRenderer = {
|
||||
tools: ["question"],
|
||||
getAction: () => "Awaiting answers...",
|
||||
getTitle({ toolState }) {
|
||||
getAction: ({ t }) => t("toolCall.question.action.awaitingAnswers"),
|
||||
getTitle({ toolState, t }) {
|
||||
const state = toolState()
|
||||
if (!state) return "Questions"
|
||||
if (state.status === "completed") return "Questions"
|
||||
return "Asking questions"
|
||||
if (!state) return t("toolCall.question.title.questions")
|
||||
if (state.status === "completed") return t("toolCall.question.title.questions")
|
||||
return t("toolCall.question.title.askingQuestions")
|
||||
},
|
||||
renderBody() {
|
||||
// The question tool UI is rendered by ToolCall itself so
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
|
||||
export const readRenderer: ToolRenderer = {
|
||||
tools: ["read"],
|
||||
getAction: () => "Reading file...",
|
||||
getAction: () => tGlobal("toolCall.renderer.action.readingFile"),
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
@@ -15,11 +16,11 @@ export const readRenderer: ToolRenderer = {
|
||||
const detailParts: string[] = []
|
||||
|
||||
if (typeof offset === "number") {
|
||||
detailParts.push(`Offset: ${offset}`)
|
||||
detailParts.push(tGlobal("toolCall.renderer.read.detail.offset", { offset }))
|
||||
}
|
||||
|
||||
if (typeof limit === "number") {
|
||||
detailParts.push(`Limit: ${limit}`)
|
||||
detailParts.push(tGlobal("toolCall.renderer.read.detail.limit", { limit }))
|
||||
}
|
||||
|
||||
const baseTitle = relativePath ? `${getToolName("read")} ${relativePath}` : getToolName("read")
|
||||
|
||||
@@ -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
|
||||
@@ -37,18 +104,7 @@ function summarizeStatusIcon(status?: ToolState["status"]) {
|
||||
}
|
||||
|
||||
function summarizeStatusLabel(status?: ToolState["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Pending"
|
||||
case "running":
|
||||
return "Running"
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "error":
|
||||
return "Error"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
function describeTaskTitle(input: Record<string, any>) {
|
||||
@@ -82,14 +138,70 @@ function describeToolTitle(item: TaskSummaryItem): string {
|
||||
|
||||
export const taskRenderer: ToolRenderer = {
|
||||
tools: ["task"],
|
||||
getAction: () => "Delegating...",
|
||||
getAction: ({ t }) => t("toolCall.task.action.delegating"),
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown }) {
|
||||
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
|
||||
@@ -128,13 +240,13 @@ export const taskRenderer: ToolRenderer = {
|
||||
const headerMeta = createMemo(() => {
|
||||
const agent = agentLabel()
|
||||
const model = modelLabel()
|
||||
if (agent && model) return `Agent: ${agent} • Model: ${model}`
|
||||
if (agent) return `Agent: ${agent}`
|
||||
if (model) return `Model: ${model}`
|
||||
if (agent && model) return t("toolCall.task.meta.agentModel", { agent, model })
|
||||
if (agent) return t("toolCall.task.meta.agent", { agent })
|
||||
if (model) return t("toolCall.task.meta.model", { model })
|
||||
return null
|
||||
})
|
||||
|
||||
const items = createMemo(() => {
|
||||
const legacyItems = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
partVersion?.()
|
||||
@@ -142,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[]) : []
|
||||
|
||||
@@ -162,7 +277,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
<Show when={promptContent()}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Prompt</span>
|
||||
<span class="tool-call-task-section-title">{t("toolCall.task.sections.prompt")}</span>
|
||||
<Show when={headerMeta()}>
|
||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||
</Show>
|
||||
@@ -178,48 +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">Steps</span>
|
||||
<span class="tool-call-task-section-meta">{items().length} 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: 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={(element) => scrollHelpers?.registerContainer(element)}
|
||||
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 statusLabel = summarizeStatusLabel(status)
|
||||
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>
|
||||
@@ -227,7 +378,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
<Show when={outputContent()}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Output</span>
|
||||
<span class="tool-call-task-section-title">{t("toolCall.task.sections.output")}</span>
|
||||
<Show when={headerMeta()}>
|
||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||
</Show>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { readToolStatePayload } from "../utils"
|
||||
import { useI18n, tGlobal } from "../../../lib/i18n"
|
||||
|
||||
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
||||
|
||||
@@ -45,16 +46,16 @@ function summarizeTodos(todos: TodoViewItem[]) {
|
||||
)
|
||||
}
|
||||
|
||||
function getTodoStatusLabel(status: TodoViewStatus): string {
|
||||
function getTodoStatusLabel(t: (key: string) => string, status: TodoViewStatus): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
return t("toolCall.renderer.todo.status.completed")
|
||||
case "in_progress":
|
||||
return "In progress"
|
||||
return t("toolCall.renderer.todo.status.inProgress")
|
||||
case "cancelled":
|
||||
return "Cancelled"
|
||||
return t("toolCall.renderer.todo.status.cancelled")
|
||||
default:
|
||||
return "Pending"
|
||||
return t("toolCall.renderer.todo.status.pending")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +66,12 @@ interface TodoListViewProps {
|
||||
}
|
||||
|
||||
export function TodoListView(props: TodoListViewProps) {
|
||||
const { t } = useI18n()
|
||||
const todos = extractTodosFromState(props.state)
|
||||
const counts = summarizeTodos(todos)
|
||||
|
||||
if (counts.total === 0) {
|
||||
return <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
|
||||
return <div class="tool-call-todo-empty">{props.emptyLabel ?? t("toolCall.renderer.todo.empty")}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -77,7 +79,7 @@ export function TodoListView(props: TodoListViewProps) {
|
||||
<div class="tool-call-todos" role="list">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const label = getTodoStatusLabel(todo.status)
|
||||
const label = getTodoStatusLabel(t, todo.status)
|
||||
return (
|
||||
<div
|
||||
class="tool-call-todo-item"
|
||||
@@ -108,20 +110,20 @@ export function TodoListView(props: TodoListViewProps) {
|
||||
}
|
||||
|
||||
export function getTodoTitle(state?: ToolState): string {
|
||||
if (!state) return "Plan"
|
||||
if (!state) return tGlobal("toolCall.renderer.todo.title.plan")
|
||||
|
||||
const todos = extractTodosFromState(state)
|
||||
if (state.status !== "completed" || todos.length === 0) return "Plan"
|
||||
if (state.status !== "completed" || todos.length === 0) return tGlobal("toolCall.renderer.todo.title.plan")
|
||||
|
||||
const counts = summarizeTodos(todos)
|
||||
if (counts.pending === counts.total) return "Creating plan"
|
||||
if (counts.completed === counts.total) return "Completing plan"
|
||||
return "Updating plan"
|
||||
if (counts.pending === counts.total) return tGlobal("toolCall.renderer.todo.title.creating")
|
||||
if (counts.completed === counts.total) return tGlobal("toolCall.renderer.todo.title.completing")
|
||||
return tGlobal("toolCall.renderer.todo.title.updating")
|
||||
}
|
||||
|
||||
export const todoRenderer: ToolRenderer = {
|
||||
tools: ["todowrite", "todoread"],
|
||||
getAction: () => "Planning...",
|
||||
getAction: () => tGlobal("toolCall.renderer.action.planning"),
|
||||
getTitle({ toolState }) {
|
||||
return getTodoTitle(toolState())
|
||||
},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
|
||||
export const webfetchRenderer: ToolRenderer = {
|
||||
tools: ["webfetch"],
|
||||
getAction: () => "Fetching from the web...",
|
||||
getAction: () => tGlobal("toolCall.renderer.action.fetchingFromWeb"),
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
|
||||
export const writeRenderer: ToolRenderer = {
|
||||
tools: ["write"],
|
||||
getAction: () => "Preparing write...",
|
||||
getAction: () => tGlobal("toolCall.renderer.action.preparingWrite"),
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||
import { enMessages } from "../../lib/i18n/messages/en"
|
||||
import { defaultRenderer } from "./renderers/default"
|
||||
import { bashRenderer } from "./renderers/bash"
|
||||
import { readRenderer } from "./renderers/read"
|
||||
@@ -43,12 +44,28 @@ function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
|
||||
} as ToolCallPart
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Record<string, unknown>): string {
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const value = params[key]
|
||||
return value === undefined || value === null ? "" : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
function createStaticT(): ToolRendererContext["t"] {
|
||||
return (key, params) => {
|
||||
const template = (enMessages as Record<string, string>)[key] ?? key
|
||||
return interpolate(template, params)
|
||||
}
|
||||
}
|
||||
|
||||
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||
const toolStateAccessor = () => snapshot.state
|
||||
const toolNameAccessor = () => snapshot.toolName
|
||||
const toolCallAccessor = () => createStaticToolPart(snapshot)
|
||||
const messageVersionAccessor = () => undefined
|
||||
const partVersionAccessor = () => undefined
|
||||
const t = createStaticT()
|
||||
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
|
||||
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
|
||||
const renderDiff: ToolRendererContext["renderDiff"] = () => null
|
||||
@@ -57,11 +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,11 +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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getLanguageFromPath } from "../../lib/markdown"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { DiffPayload } from "./types"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { tGlobal } from "../../lib/i18n"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -61,16 +62,16 @@ export function getToolIcon(tool: string): string {
|
||||
export function getToolName(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "Shell"
|
||||
return tGlobal("toolCall.renderer.toolName.shell")
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
return tGlobal("toolCall.renderer.toolName.fetch")
|
||||
case "invalid":
|
||||
return "Invalid"
|
||||
return tGlobal("toolCall.renderer.toolName.invalid")
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
return tGlobal("toolCall.renderer.toolName.plan")
|
||||
case "apply_patch":
|
||||
return "Apply patch"
|
||||
return tGlobal("toolCall.renderer.toolName.applyPatch")
|
||||
default: {
|
||||
const normalized = tool.replace(/^opencode_/, "")
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
@@ -202,31 +203,31 @@ export function readToolStatePayload(state?: ToolState): {
|
||||
export function getDefaultToolAction(toolName: string) {
|
||||
switch (toolName) {
|
||||
case "task":
|
||||
return "Delegating..."
|
||||
return tGlobal("toolCall.task.action.delegating")
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
return tGlobal("toolCall.renderer.action.writingCommand")
|
||||
case "edit":
|
||||
return "Preparing edit..."
|
||||
return tGlobal("toolCall.renderer.action.preparingEdit")
|
||||
case "webfetch":
|
||||
return "Fetching from the web..."
|
||||
return tGlobal("toolCall.renderer.action.fetchingFromWeb")
|
||||
case "glob":
|
||||
return "Finding files..."
|
||||
return tGlobal("toolCall.renderer.action.findingFiles")
|
||||
case "grep":
|
||||
return "Searching content..."
|
||||
return tGlobal("toolCall.renderer.action.searchingContent")
|
||||
case "list":
|
||||
return "Listing directory..."
|
||||
return tGlobal("toolCall.renderer.action.listingDirectory")
|
||||
case "read":
|
||||
return "Reading file..."
|
||||
return tGlobal("toolCall.renderer.action.readingFile")
|
||||
case "write":
|
||||
return "Preparing write..."
|
||||
return tGlobal("toolCall.renderer.action.preparingWrite")
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning..."
|
||||
return tGlobal("toolCall.renderer.action.planning")
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
return tGlobal("toolCall.renderer.action.preparingPatch")
|
||||
case "apply_patch":
|
||||
return "Preparing apply_patch..."
|
||||
return tGlobal("toolCall.applyPatch.action.preparing")
|
||||
default:
|
||||
return "Working..."
|
||||
return tGlobal("toolCall.renderer.action.working")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Agent } from "../types/session"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -87,6 +88,7 @@ interface UnifiedPickerProps {
|
||||
}
|
||||
|
||||
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const mode = () => props.mode ?? "mention"
|
||||
|
||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||
@@ -366,10 +368,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
const isLoading = () => mode() === "mention" && loadingState() !== "idle"
|
||||
const loadingMessage = () => {
|
||||
if (loadingState() === "search") {
|
||||
return "Searching..."
|
||||
return t("unifiedPicker.loading.searching")
|
||||
}
|
||||
if (loadingState() === "listing") {
|
||||
return "Loading workspace..."
|
||||
return t("unifiedPicker.loading.loadingWorkspace")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -383,8 +385,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
>
|
||||
<div class="dropdown-header">
|
||||
<div class="dropdown-header-title">
|
||||
<Show when={mode() === "command"} fallback={"Select Agent or File"}>
|
||||
Select Command
|
||||
<Show when={mode() === "command"} fallback={t("unifiedPicker.title.mention")}>
|
||||
{t("unifiedPicker.title.command")}
|
||||
</Show>
|
||||
<Show when={isLoading()}>
|
||||
<span class="ml-2">{loadingMessage()}</span>
|
||||
@@ -394,11 +396,11 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
|
||||
<Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}>
|
||||
<div class="dropdown-empty">No results found</div>
|
||||
<div class="dropdown-empty">{t("unifiedPicker.empty")}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={mode() === "command" && commandCount() > 0}>
|
||||
<div class="dropdown-section-header">COMMANDS</div>
|
||||
<div class="dropdown-section-header">{t("unifiedPicker.sections.commands")}</div>
|
||||
<For each={filteredCommands()}>
|
||||
{(command) => {
|
||||
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
|
||||
@@ -429,7 +431,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
<Show when={mode() === "mention" && agentCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
AGENTS
|
||||
{t("unifiedPicker.sections.agents")}
|
||||
</div>
|
||||
<For each={filteredAgents()}>
|
||||
{(agent) => {
|
||||
@@ -463,7 +465,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
<span class="text-sm font-medium">{agent.name}</span>
|
||||
<Show when={agent.mode === "subagent"}>
|
||||
<span class="dropdown-badge">
|
||||
subagent
|
||||
{t("unifiedPicker.badge.subagent")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -484,7 +486,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
<Show when={mode() === "mention" && fileCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
FILES
|
||||
{t("unifiedPicker.sections.files")}
|
||||
</div>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
@@ -534,8 +536,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
<div class="dropdown-footer">
|
||||
<div>
|
||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Tab/Enter</span> select •{" "}
|
||||
<span class="font-medium">Esc</span> close
|
||||
<span class="font-medium">↑↓</span> {t("unifiedPicker.footer.navigate")} • <span class="font-medium">Tab/Enter</span> {t("unifiedPicker.footer.select")} •{" "}
|
||||
<span class="font-medium">Esc</span> {t("unifiedPicker.footer.close")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Show, createEffect, createSignal } from "solid-js"
|
||||
import type { ServerMeta } from "../../../server/src/api-types"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
export default function VersionPill() {
|
||||
const { t } = useI18n()
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -15,11 +17,13 @@ export default function VersionPill() {
|
||||
const uiVersion = () => meta()?.ui?.version
|
||||
const uiSource = () => meta()?.ui?.source
|
||||
|
||||
const uiLabel = () => (uiVersion() ? t("versionPill.uiWithVersion", { version: uiVersion() }) : t("versionPill.ui"))
|
||||
|
||||
return (
|
||||
<Show when={serverVersion() || uiVersion() || uiSource()}>
|
||||
<div class="text-[11px] text-muted whitespace-nowrap">
|
||||
<Show when={serverVersion()}>
|
||||
{(v) => <span>App {v()}</span>}
|
||||
{(v) => <span>{t("versionPill.appWithVersion", { version: v() })}</span>}
|
||||
</Show>
|
||||
<Show when={uiVersion() || uiSource()}>
|
||||
<>
|
||||
@@ -27,8 +31,8 @@ export default function VersionPill() {
|
||||
<span class="mx-2">·</span>
|
||||
</Show>
|
||||
<span>
|
||||
UI{uiVersion() ? ` ${uiVersion()}` : ""}
|
||||
<Show when={uiSource()}>{(s) => <span class="opacity-70"> ({s()})</span>}</Show>
|
||||
{uiLabel()}
|
||||
<Show when={uiSource()}>{(s) => <span class="opacity-70">{t("versionPill.source", { source: s() })}</span>}</Show>
|
||||
</span>
|
||||
</>
|
||||
</Show>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
||||
import { getLogger } from "./logger"
|
||||
import { tGlobal } from "./i18n"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -17,19 +18,19 @@ export async function promptForCommandArguments(command: SDKCommand): Promise<st
|
||||
}
|
||||
|
||||
try {
|
||||
return await showPromptDialog(`Arguments for /${command.name}`, {
|
||||
title: "Custom command",
|
||||
return await showPromptDialog(tGlobal("commands.custom.argumentsPrompt.message", { name: command.name }), {
|
||||
title: tGlobal("commands.custom.argumentsPrompt.title"),
|
||||
variant: "info",
|
||||
inputLabel: "Arguments",
|
||||
inputPlaceholder: "e.g. foo bar",
|
||||
inputLabel: tGlobal("commands.custom.argumentsPrompt.inputLabel"),
|
||||
inputPlaceholder: tGlobal("commands.custom.argumentsPrompt.inputPlaceholder"),
|
||||
inputDefaultValue: "",
|
||||
confirmLabel: "Run",
|
||||
cancelLabel: "Cancel",
|
||||
confirmLabel: tGlobal("commands.custom.argumentsPrompt.confirmLabel"),
|
||||
cancelLabel: tGlobal("commands.custom.argumentsPrompt.cancelLabel"),
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to prompt for command arguments", error)
|
||||
showAlertDialog("Failed to open arguments prompt.", {
|
||||
title: "Command arguments",
|
||||
showAlertDialog(tGlobal("commands.custom.argumentsPrompt.openFailed.message"), {
|
||||
title: tGlobal("commands.custom.argumentsPrompt.openFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return null
|
||||
@@ -45,14 +46,14 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
|
||||
return commands.map((cmd) => ({
|
||||
id: `custom:${instanceId}:${cmd.name}`,
|
||||
label: formatCommandLabel(cmd.name),
|
||||
description: cmd.description ?? "Custom command",
|
||||
description: () => cmd.description ?? tGlobal("commands.custom.entries.descriptionFallback"),
|
||||
category: "Custom Commands",
|
||||
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
|
||||
action: async () => {
|
||||
const sessionId = activeSessionId().get(instanceId)
|
||||
if (!sessionId || sessionId === "info") {
|
||||
showAlertDialog("Select a session before running a custom command.", {
|
||||
title: "Session required",
|
||||
showAlertDialog(tGlobal("commands.custom.sessionRequired.message"), {
|
||||
title: tGlobal("commands.custom.sessionRequired.title"),
|
||||
variant: "warning",
|
||||
})
|
||||
return
|
||||
@@ -65,8 +66,8 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
|
||||
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
||||
} catch (error) {
|
||||
log.error("Failed to run custom command", error)
|
||||
showAlertDialog("Failed to run custom command. Check the console for details.", {
|
||||
title: "Command failed",
|
||||
showAlertDialog(tGlobal("commands.custom.runFailed.message"), {
|
||||
title: tGlobal("commands.custom.runFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,14 +6,20 @@ export interface KeyboardShortcut {
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
export type Resolvable<T> = T | (() => T)
|
||||
|
||||
export function resolveResolvable<T>(value: Resolvable<T>): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: string
|
||||
label: string | (() => string)
|
||||
description: string
|
||||
keywords?: string[]
|
||||
label: Resolvable<string>
|
||||
description: Resolvable<string>
|
||||
keywords?: Resolvable<string[]>
|
||||
shortcut?: KeyboardShortcut
|
||||
action: () => void | Promise<void>
|
||||
category?: string
|
||||
category?: Resolvable<string>
|
||||
}
|
||||
|
||||
export function createCommandRegistry() {
|
||||
@@ -47,11 +53,15 @@ export function createCommandRegistry() {
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return getAll().filter((cmd) => {
|
||||
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
||||
const label = resolveResolvable(cmd.label)
|
||||
const description = resolveResolvable(cmd.description)
|
||||
const keywords = cmd.keywords ? resolveResolvable(cmd.keywords) : undefined
|
||||
const category = cmd.category ? resolveResolvable(cmd.category) : undefined
|
||||
const labelMatch = label.toLowerCase().includes(lowerQuery)
|
||||
const descMatch = cmd.description.toLowerCase().includes(lowerQuery)
|
||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
|
||||
return labelMatch || descMatch || keywordMatch
|
||||
const descMatch = description.toLowerCase().includes(lowerQuery)
|
||||
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
|
||||
const categoryMatch = category?.toLowerCase().includes(lowerQuery)
|
||||
return labelMatch || descMatch || keywordMatch || categoryMatch
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,20 @@ export function formatTokenTotal(value: number): string {
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatCompactCount(value: number): string {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const label = `${(value / 1_000).toFixed(1)}K`
|
||||
return label.replace(/\.0K$/, "K")
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||
},
|
||||
() => {
|
||||
const instance = options.getActiveInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
)
|
||||
|
||||
registerEscapeShortcut(
|
||||
|
||||
@@ -13,9 +13,17 @@ import { cleanupBlankSessions } from "../../stores/session-state"
|
||||
import { getLogger } from "../logger"
|
||||
import { requestData } from "../opencode-api"
|
||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||
import { tGlobal } from "../i18n"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
function splitKeywords(key: string): string[] {
|
||||
return tGlobal(key)
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
@@ -61,20 +69,20 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "new-instance",
|
||||
label: "New Instance",
|
||||
description: "Open folder picker to create new instance",
|
||||
label: () => tGlobal("commands.newInstance.label"),
|
||||
description: () => tGlobal("commands.newInstance.description"),
|
||||
category: "Instance",
|
||||
keywords: ["folder", "project", "workspace"],
|
||||
keywords: () => splitKeywords("commands.newInstance.keywords"),
|
||||
shortcut: { key: "N", meta: true },
|
||||
action: options.handleNewInstanceRequest,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "close-instance",
|
||||
label: "Close Instance",
|
||||
description: "Stop current instance's server",
|
||||
label: () => tGlobal("commands.closeInstance.label"),
|
||||
description: () => tGlobal("commands.closeInstance.description"),
|
||||
category: "Instance",
|
||||
keywords: ["stop", "quit", "close"],
|
||||
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
||||
shortcut: { key: "W", meta: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
@@ -85,10 +93,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "instance-next",
|
||||
label: "Next Instance",
|
||||
description: "Cycle to next instance tab",
|
||||
label: () => tGlobal("commands.nextInstance.label"),
|
||||
description: () => tGlobal("commands.nextInstance.description"),
|
||||
category: "Instance",
|
||||
keywords: ["switch", "navigate"],
|
||||
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
||||
shortcut: { key: "]", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
@@ -101,10 +109,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "instance-prev",
|
||||
label: "Previous Instance",
|
||||
description: "Cycle to previous instance tab",
|
||||
label: () => tGlobal("commands.previousInstance.label"),
|
||||
description: () => tGlobal("commands.previousInstance.description"),
|
||||
category: "Instance",
|
||||
keywords: ["switch", "navigate"],
|
||||
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
||||
shortcut: { key: "[", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
@@ -117,10 +125,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "new-session",
|
||||
label: "New Session",
|
||||
description: "Create a new parent session",
|
||||
label: () => tGlobal("commands.newSession.label"),
|
||||
description: () => tGlobal("commands.newSession.description"),
|
||||
category: "Session",
|
||||
keywords: ["create", "start"],
|
||||
keywords: () => splitKeywords("commands.newSession.keywords"),
|
||||
shortcut: { key: "N", meta: true, shift: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
@@ -131,10 +139,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "close-session",
|
||||
label: "Close Session",
|
||||
description: "Close current parent session",
|
||||
label: () => tGlobal("commands.closeSession.label"),
|
||||
description: () => tGlobal("commands.closeSession.description"),
|
||||
category: "Session",
|
||||
keywords: ["close", "stop"],
|
||||
keywords: () => splitKeywords("commands.closeSession.keywords"),
|
||||
shortcut: { key: "W", meta: true, shift: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
@@ -146,10 +154,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "cleanup-blank-sessions",
|
||||
label: "Scrub Sessions",
|
||||
description: "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
|
||||
label: () => tGlobal("commands.scrubSessions.label"),
|
||||
description: () => tGlobal("commands.scrubSessions.description"),
|
||||
category: "Session",
|
||||
keywords: ["cleanup", "blank", "empty", "sessions", "remove", "delete", "scrub"],
|
||||
keywords: () => splitKeywords("commands.scrubSessions.keywords"),
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
@@ -159,10 +167,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "switch-to-info",
|
||||
label: "Instance Info",
|
||||
description: "Open the instance overview for logs and status",
|
||||
label: () => tGlobal("commands.instanceInfo.label"),
|
||||
description: () => tGlobal("commands.instanceInfo.description"),
|
||||
category: "Instance",
|
||||
keywords: ["info", "logs", "console", "output"],
|
||||
keywords: () => splitKeywords("commands.instanceInfo.keywords"),
|
||||
shortcut: { key: "L", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
@@ -172,10 +180,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "session-next",
|
||||
label: "Next Session",
|
||||
description: "Cycle to next session tab",
|
||||
label: () => tGlobal("commands.nextSession.label"),
|
||||
description: () => tGlobal("commands.nextSession.description"),
|
||||
category: "Session",
|
||||
keywords: ["switch", "navigate"],
|
||||
keywords: () => splitKeywords("commands.nextSession.keywords"),
|
||||
shortcut: { key: "]", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
@@ -197,10 +205,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "session-prev",
|
||||
label: "Previous Session",
|
||||
description: "Cycle to previous session tab",
|
||||
label: () => tGlobal("commands.previousSession.label"),
|
||||
description: () => tGlobal("commands.previousSession.description"),
|
||||
category: "Session",
|
||||
keywords: ["switch", "navigate"],
|
||||
keywords: () => splitKeywords("commands.previousSession.keywords"),
|
||||
shortcut: { key: "[", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
@@ -223,10 +231,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "compact",
|
||||
label: "Compact Session",
|
||||
description: "Summarize and compact the current session",
|
||||
label: () => tGlobal("commands.compactSession.label"),
|
||||
description: () => tGlobal("commands.compactSession.description"),
|
||||
category: "Session",
|
||||
keywords: ["/compact", "summarize", "compress"],
|
||||
keywords: () => ["/compact", ...splitKeywords("commands.compactSession.keywords")],
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
@@ -247,9 +255,9 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
)
|
||||
} catch (error) {
|
||||
log.error("Failed to compact session", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
||||
showAlertDialog(`Compact failed: ${message}`, {
|
||||
title: "Compact failed",
|
||||
const message = error instanceof Error ? error.message : tGlobal("commands.compactSession.errorFallback")
|
||||
showAlertDialog(tGlobal("commands.compactSession.alert.message", { message }), {
|
||||
title: tGlobal("commands.compactSession.alert.title"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
@@ -275,10 +283,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "undo",
|
||||
label: "Undo Last Message",
|
||||
description: "Revert the last message",
|
||||
label: () => tGlobal("commands.undoLastMessage.label"),
|
||||
description: () => tGlobal("commands.undoLastMessage.description"),
|
||||
category: "Session",
|
||||
keywords: ["/undo", "revert", "undo"],
|
||||
keywords: () => ["/undo", ...splitKeywords("commands.undoLastMessage.keywords")],
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
@@ -320,8 +328,8 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
}
|
||||
|
||||
if (!messageID) {
|
||||
showAlertDialog("Nothing to undo", {
|
||||
title: "No actions to undo",
|
||||
showAlertDialog(tGlobal("commands.undoLastMessage.none.message"), {
|
||||
title: tGlobal("commands.undoLastMessage.none.title"),
|
||||
variant: "info",
|
||||
})
|
||||
return
|
||||
@@ -351,8 +359,8 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to revert message", error)
|
||||
showAlertDialog("Failed to revert message", {
|
||||
title: "Undo failed",
|
||||
showAlertDialog(tGlobal("commands.undoLastMessage.failed.message"), {
|
||||
title: tGlobal("commands.undoLastMessage.failed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
@@ -362,10 +370,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-model-selector",
|
||||
label: "Open Model Selector",
|
||||
description: "Choose a different model",
|
||||
label: () => tGlobal("commands.openModelSelector.label"),
|
||||
description: () => tGlobal("commands.openModelSelector.description"),
|
||||
category: "Agent & Model",
|
||||
keywords: ["model", "llm", "ai"],
|
||||
keywords: () => splitKeywords("commands.openModelSelector.keywords"),
|
||||
shortcut: { key: "M", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
@@ -375,11 +383,25 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-agent-selector",
|
||||
label: "Open Agent Selector",
|
||||
description: "Choose a different agent",
|
||||
id: "open-variant-selector",
|
||||
label: () => tGlobal("commands.selectModelVariant.label"),
|
||||
description: () => tGlobal("commands.selectModelVariant.description"),
|
||||
category: "Agent & Model",
|
||||
keywords: ["agent", "mode"],
|
||||
keywords: () => splitKeywords("commands.selectModelVariant.keywords"),
|
||||
shortcut: { key: "T", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-agent-selector",
|
||||
label: () => tGlobal("commands.openAgentSelector.label"),
|
||||
description: () => tGlobal("commands.openAgentSelector.description"),
|
||||
category: "Agent & Model",
|
||||
keywords: () => splitKeywords("commands.openAgentSelector.keywords"),
|
||||
shortcut: { key: "A", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
@@ -390,10 +412,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "clear-input",
|
||||
label: "Clear Input",
|
||||
description: "Clear the prompt textarea",
|
||||
label: () => tGlobal("commands.clearInput.label"),
|
||||
description: () => tGlobal("commands.clearInput.description"),
|
||||
category: "Input & Focus",
|
||||
keywords: ["clear", "reset"],
|
||||
keywords: () => splitKeywords("commands.clearInput.keywords"),
|
||||
shortcut: { key: "K", meta: true },
|
||||
action: () => {
|
||||
const textarea = findVisiblePromptTextarea()
|
||||
@@ -403,19 +425,19 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "thinking",
|
||||
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
|
||||
description: "Show/hide AI thinking process",
|
||||
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
|
||||
description: () => tGlobal("commands.thinkingBlocks.description"),
|
||||
category: "System",
|
||||
keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"],
|
||||
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
|
||||
action: options.toggleShowThinkingBlocks,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "timeline-tools",
|
||||
label: () => `${options.preferences().showTimelineTools ? "Hide" : "Show"} Timeline Tool Calls`,
|
||||
description: "Toggle tool call entries in the message timeline",
|
||||
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
|
||||
description: () => tGlobal("commands.timelineToolCalls.description"),
|
||||
category: "System",
|
||||
keywords: ["timeline", "tool", "toggle"],
|
||||
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
|
||||
action: options.toggleShowTimelineTools,
|
||||
})
|
||||
|
||||
@@ -423,11 +445,12 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
id: "thinking-default-visibility",
|
||||
label: () => {
|
||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||
return `Thinking Blocks Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
||||
return tGlobal("commands.thinkingBlocksDefault.label", { state })
|
||||
},
|
||||
description: "Toggle whether thinking blocks start expanded",
|
||||
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
|
||||
category: "System",
|
||||
keywords: ["/thinking", "thinking", "reasoning", "expand", "collapse", "default"],
|
||||
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
|
||||
action: () => {
|
||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
@@ -437,19 +460,25 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
|
||||
commandRegistry.register({
|
||||
id: "diff-view-split",
|
||||
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
|
||||
description: "Display tool-call diffs side-by-side",
|
||||
label: () => {
|
||||
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
|
||||
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
|
||||
},
|
||||
description: () => tGlobal("commands.diffViewSplit.description"),
|
||||
category: "System",
|
||||
keywords: ["diff", "split", "view"],
|
||||
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
|
||||
action: () => options.setDiffViewMode("split"),
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "diff-view-unified",
|
||||
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`,
|
||||
description: "Display tool-call diffs inline",
|
||||
label: () => {
|
||||
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
|
||||
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
|
||||
},
|
||||
description: () => tGlobal("commands.diffViewUnified.description"),
|
||||
category: "System",
|
||||
keywords: ["diff", "unified", "view"],
|
||||
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
|
||||
action: () => options.setDiffViewMode("unified"),
|
||||
})
|
||||
|
||||
@@ -457,11 +486,12 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
id: "tool-output-default-visibility",
|
||||
label: () => {
|
||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
||||
return `Tool Outputs Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
||||
return tGlobal("commands.toolOutputsDefault.label", { state })
|
||||
},
|
||||
description: "Toggle default expansion for tool outputs",
|
||||
description: () => tGlobal("commands.toolOutputsDefault.description"),
|
||||
category: "System",
|
||||
keywords: ["tool", "output", "expand", "collapse"],
|
||||
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
|
||||
action: () => {
|
||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
@@ -473,11 +503,12 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
id: "diagnostics-default-visibility",
|
||||
label: () => {
|
||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
||||
return `Diagnostics Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
||||
return tGlobal("commands.diagnosticsDefault.label", { state })
|
||||
},
|
||||
description: "Toggle default expansion for diagnostics output",
|
||||
description: () => tGlobal("commands.diagnosticsDefault.description"),
|
||||
category: "System",
|
||||
keywords: ["diagnostics", "expand", "collapse"],
|
||||
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
|
||||
action: () => {
|
||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
@@ -489,11 +520,12 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
id: "token-usage-visibility",
|
||||
label: () => {
|
||||
const visible = options.preferences().showUsageMetrics ?? true
|
||||
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
|
||||
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
|
||||
return tGlobal("commands.tokenUsageDisplay.label", { state })
|
||||
},
|
||||
description: "Show or hide token and cost stats for assistant messages",
|
||||
description: () => tGlobal("commands.tokenUsageDisplay.description"),
|
||||
category: "System",
|
||||
keywords: ["token", "usage", "cost", "stats"],
|
||||
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
|
||||
action: options.toggleUsageMetrics,
|
||||
})
|
||||
|
||||
@@ -501,21 +533,21 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
id: "auto-cleanup-blank-sessions",
|
||||
label: () => {
|
||||
const enabled = options.preferences().autoCleanupBlankSessions
|
||||
return `Auto-Cleanup Blank Sessions · ${enabled ? "Enabled" : "Disabled"}`
|
||||
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
|
||||
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
|
||||
},
|
||||
description: "Automatically clean up blank sessions when creating new ones",
|
||||
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
|
||||
category: "System",
|
||||
keywords: ["auto", "cleanup", "blank", "sessions", "toggle"],
|
||||
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
|
||||
action: options.toggleAutoCleanupBlankSessions,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "help",
|
||||
label: "Show Help",
|
||||
|
||||
description: "Display keyboard shortcuts and help",
|
||||
label: () => tGlobal("commands.showHelp.label"),
|
||||
description: () => tGlobal("commands.showHelp.description"),
|
||||
category: "System",
|
||||
keywords: ["/help", "shortcuts", "help"],
|
||||
keywords: () => ["/help", ...splitKeywords("commands.showHelp.keywords")],
|
||||
action: () => {
|
||||
log.info("Show help modal (not implemented)")
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user