Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f5f830a3 | ||
|
|
81102cc6bf | ||
|
|
afa7243eab | ||
|
|
37b7c1e53c | ||
|
|
ba61ab79e2 | ||
|
|
37d075fbb3 | ||
|
|
2961d41be3 | ||
|
|
1bb5aedfdb | ||
|
|
0a793fb1c6 | ||
|
|
a401eeec11 | ||
|
|
d9bcc66930 | ||
|
|
01921e3454 | ||
|
|
158f6e25cf | ||
|
|
562c4b2637 | ||
|
|
51fd5d87f7 | ||
|
|
28fb56bfa1 | ||
|
|
c1052b36dc | ||
|
|
c62c9b1c78 | ||
|
|
feccbd13bd | ||
|
|
5b1e21345f | ||
|
|
33939f4096 |
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -1419,6 +1419,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/cli": {
|
"node_modules/@tauri-apps/cli": {
|
||||||
"version": "2.9.4",
|
"version": "2.9.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1462,6 +1472,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/once": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7384,7 +7403,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -7418,7 +7437,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -7455,14 +7474,14 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
@@ -7471,6 +7490,7 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.9.1",
|
"minServerVersion": "0.9.2",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.30"
|
"@opencode-ai/plugin": "1.1.36"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,17 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
| `--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.
|
||||||
|
|
||||||
### Data Storage
|
### Data Storage
|
||||||
- **Config**: `~/.config/codenomad/config.json`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||||
|
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -15,15 +15,25 @@ export interface AuthManagerInit {
|
|||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
|
dangerouslySkipAuth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
private readonly authStore: AuthStore
|
private readonly authStore: AuthStore | null
|
||||||
private readonly tokenManager: TokenManager | null
|
private readonly tokenManager: TokenManager | null
|
||||||
private readonly sessionManager = new SessionManager()
|
private readonly sessionManager = new SessionManager()
|
||||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
private readonly authEnabled: boolean
|
||||||
|
|
||||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
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)
|
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||||
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
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
|
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAuthEnabled(): boolean {
|
||||||
|
return this.authEnabled
|
||||||
|
}
|
||||||
|
|
||||||
getCookieName(): string {
|
getCookieName(): string {
|
||||||
return this.cookieName
|
return this.cookieName
|
||||||
}
|
}
|
||||||
@@ -56,19 +70,31 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateLogin(username: string, password: string): boolean {
|
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) {
|
createSession(username: string) {
|
||||||
|
if (!this.authEnabled) {
|
||||||
|
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
|
||||||
|
}
|
||||||
return this.sessionManager.createSession(username)
|
return this.sessionManager.createSession(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatus() {
|
getStatus() {
|
||||||
return this.authStore.getStatus()
|
if (!this.authEnabled) {
|
||||||
|
return { username: this.init.username, passwordUserProvided: false }
|
||||||
|
}
|
||||||
|
return this.requireAuthStore().getStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
setPassword(password: string) {
|
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 {
|
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||||
@@ -76,6 +102,12 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
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 cookies = parseCookies(request.headers.cookie)
|
||||||
const sessionId = cookies[this.cookieName]
|
const sessionId = cookies[this.cookieName]
|
||||||
const session = this.sessionManager.getSession(sessionId)
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
@@ -90,6 +122,13 @@ export class AuthManager {
|
|||||||
clearSessionCookie(reply: FastifyReply) {
|
clearSessionCookie(reply: FastifyReply) {
|
||||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
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) {
|
function resolveAuthFilePath(configPath: string) {
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ const PreferencesSchema = z.object({
|
|||||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showTimelineTools: z.boolean().default(true),
|
showTimelineTools: z.boolean().default(true),
|
||||||
lastUsedBinary: z.string().optional(),
|
lastUsedBinary: z.string().optional(),
|
||||||
|
locale: z.string().optional(),
|
||||||
environmentVariables: z.record(z.string()).default({}),
|
environmentVariables: z.record(z.string()).default({}),
|
||||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||||
|
modelFavorites: z.array(ModelPreferenceSchema).default([]),
|
||||||
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface CliOptions {
|
|||||||
authUsername: string
|
authUsername: string
|
||||||
authPassword?: string
|
authPassword?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT = 9898
|
const DEFAULT_PORT = 9898
|
||||||
@@ -84,6 +85,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.env("CODENOMAD_GENERATE_TOKEN")
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
.default(false),
|
.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" })
|
program.parse(argv, { from: "user" })
|
||||||
const parsed = program.opts<{
|
const parsed = program.opts<{
|
||||||
@@ -104,8 +113,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
generateToken?: boolean
|
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 resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||||
|
|
||||||
const normalizedHost = resolveHost(parsed.host)
|
const normalizedHost = resolveHost(parsed.host)
|
||||||
@@ -130,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
authUsername: parsed.username,
|
authUsername: parsed.username,
|
||||||
authPassword: parsed.password,
|
authPassword: parsed.password,
|
||||||
generateToken: Boolean(parsed.generateToken),
|
generateToken: Boolean(parsed.generateToken),
|
||||||
|
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +190,12 @@ async function main() {
|
|||||||
|
|
||||||
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
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 eventBus = new EventBus(eventLogger)
|
||||||
|
|
||||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
@@ -195,11 +217,12 @@ async function main() {
|
|||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
|
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||||
},
|
},
|
||||||
logger.child({ component: "auth" }),
|
logger.child({ component: "auth" }),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (options.generateToken) {
|
if (options.generateToken && !options.dangerouslySkipAuth) {
|
||||||
const token = authManager.issueBootstrapToken()
|
const token = authManager.issueBootstrapToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||||
|
|||||||
@@ -380,6 +380,16 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
if (instanceAuthHeader) {
|
if (instanceAuthHeader) {
|
||||||
headers.authorization = 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
|
return headers
|
||||||
},
|
},
|
||||||
onError: (proxyReply, { error }) => {
|
onError: (proxyReply, { error }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"identifier": "main-window-native-dialogs",
|
"identifier": "main-window-native-dialogs",
|
||||||
"description": "Grant the main window access to required core features and native dialog commands.",
|
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||||
"remote": {
|
"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"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"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"]}}
|
||||||
@@ -464,13 +464,33 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let code = {
|
// Do not hold the child mutex while waiting for process exit.
|
||||||
let mut guard = child_holder.lock();
|
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
|
||||||
if let Some(child) = guard.as_mut() {
|
// same lock to send SIGTERM/SIGKILL when the user quits the app.
|
||||||
child.wait().ok()
|
let code = loop {
|
||||||
} else {
|
let maybe_exited = {
|
||||||
None
|
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();
|
let mut locked = status_clone.lock();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod cli_manager;
|
|||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
@@ -11,6 +12,8 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
@@ -39,7 +42,10 @@ fn is_dev_mode() -> bool {
|
|||||||
fn should_allow_internal(url: &Url) -> bool {
|
fn should_allow_internal(url: &Url) -> bool {
|
||||||
match url.scheme() {
|
match url.scheme() {
|
||||||
"tauri" | "asset" | "file" => true,
|
"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,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,6 +170,11 @@ fn main() {
|
|||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app_handle, event| match event {
|
.run(|app_handle, event| match event {
|
||||||
tauri::RunEvent::ExitRequested { api, .. } => {
|
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();
|
api.prevent_exit();
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -178,6 +189,9 @@ fn main() {
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Ensure we have time to stop the CLI process before the app exits.
|
// Ensure we have time to stop the CLI process before the app exits.
|
||||||
|
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.9.1",
|
"version": "0.9.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
|||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
|
import { useI18n } from "./lib/i18n"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -51,6 +52,7 @@ const log = getLogger("actions")
|
|||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
@@ -119,7 +121,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||||
if (!error) {
|
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)
|
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||||
try {
|
try {
|
||||||
@@ -202,12 +204,12 @@ const App: Component = () => {
|
|||||||
|
|
||||||
async function handleCloseInstance(instanceId: string) {
|
async function handleCloseInstance(instanceId: string) {
|
||||||
const confirmed = await showConfirmDialog(
|
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",
|
variant: "warning",
|
||||||
confirmLabel: "Stop",
|
confirmLabel: t("app.stopInstance.confirmLabel"),
|
||||||
cancelLabel: "Keep running",
|
cancelLabel: t("app.stopInstance.cancelLabel"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -330,21 +332,20 @@ const App: Component = () => {
|
|||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<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">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<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">
|
<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
|
{t("app.launchError.description")}
|
||||||
binary from Advanced Settings.
|
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<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>
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={launchErrorMessage()}>
|
<Show when={launchErrorMessage()}>
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<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>
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -356,11 +357,11 @@ const App: Component = () => {
|
|||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={handleLaunchErrorAdvanced}
|
onClick={handleLaunchErrorAdvanced}
|
||||||
>
|
>
|
||||||
Open Advanced Settings
|
{t("app.launchError.openAdvancedSettings")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||||
Close
|
{t("app.launchError.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
@@ -430,7 +431,7 @@ const App: Component = () => {
|
|||||||
clearLaunchError()
|
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"
|
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">
|
<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" />
|
<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 { Dialog } from "@kobalte/core/dialog"
|
||||||
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||||
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface AdvancedSettingsModalProps {
|
interface AdvancedSettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,6 +13,8 @@ interface AdvancedSettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
<Dialog.Portal>
|
<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">
|
<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">
|
<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)" }}>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
<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">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3 class="panel-title">Environment Variables</h3>
|
<h3 class="panel-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
||||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
<p class="panel-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||||
@@ -47,7 +50,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
Close
|
{t("advancedSettings.actions.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
|||||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import Kbd from "./kbd"
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ interface AgentSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentSelector(props: AgentSelectorProps) {
|
export default function AgentSelector(props: AgentSelectorProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const instanceAgents = () => agents().get(props.instanceId) || []
|
const instanceAgents = () => agents().get(props.instanceId) || []
|
||||||
|
|
||||||
const session = createMemo(() => {
|
const session = createMemo(() => {
|
||||||
@@ -72,7 +73,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
options={availableAgents()}
|
options={availableAgents()}
|
||||||
optionValue="name"
|
optionValue="name"
|
||||||
optionTextValue="name"
|
optionTextValue="name"
|
||||||
placeholder="Select agent..."
|
placeholder={t("agentSelector.placeholder")}
|
||||||
itemComponent={(itemProps) => (
|
itemComponent={(itemProps) => (
|
||||||
<Select.Item
|
<Select.Item
|
||||||
item={itemProps.item}
|
item={itemProps.item}
|
||||||
@@ -82,7 +83,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
||||||
<span>{itemProps.item.rawValue.name}</span>
|
<span>{itemProps.item.rawValue.name}</span>
|
||||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
||||||
<span class="neutral-badge">subagent</span>
|
<span class="neutral-badge">{t("agentSelector.badge.subagent")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</Select.ItemLabel>
|
</Select.ItemLabel>
|
||||||
<Show when={itemProps.item.rawValue.description}>
|
<Show when={itemProps.item.rawValue.description}>
|
||||||
@@ -105,15 +106,12 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
{(state) => (
|
{(state) => (
|
||||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
Agent: {state.selectedOption()?.name ?? "None"}
|
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Select.Value>
|
</Select.Value>
|
||||||
</div>
|
</div>
|
||||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
|
||||||
</span>
|
|
||||||
<Select.Icon class="selector-trigger-icon">
|
<Select.Icon class="selector-trigger-icon">
|
||||||
<ChevronDown class="w-3 h-3" />
|
<ChevronDown class="w-3 h-3" />
|
||||||
</Select.Icon>
|
</Select.Icon>
|
||||||
|
|||||||
@@ -2,28 +2,26 @@ import { Dialog } from "@kobalte/core/dialog"
|
|||||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||||
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
||||||
import type { AlertVariant, AlertDialogState } 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: {
|
info: {
|
||||||
badgeBg: "var(--badge-neutral-bg)",
|
badgeBg: "var(--badge-neutral-bg)",
|
||||||
badgeBorder: "var(--border-base)",
|
badgeBorder: "var(--border-base)",
|
||||||
badgeText: "var(--accent-primary)",
|
badgeText: "var(--accent-primary)",
|
||||||
symbol: "i",
|
symbol: "i",
|
||||||
fallbackTitle: "Heads up",
|
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
badgeBg: "rgba(255, 152, 0, 0.14)",
|
badgeBg: "rgba(255, 152, 0, 0.14)",
|
||||||
badgeBorder: "var(--status-warning)",
|
badgeBorder: "var(--status-warning)",
|
||||||
badgeText: "var(--status-warning)",
|
badgeText: "var(--status-warning)",
|
||||||
symbol: "!",
|
symbol: "!",
|
||||||
fallbackTitle: "Please review",
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
badgeBg: "var(--danger-soft-bg)",
|
badgeBg: "var(--danger-soft-bg)",
|
||||||
badgeBorder: "var(--status-error)",
|
badgeBorder: "var(--status-error)",
|
||||||
badgeText: "var(--status-error)",
|
badgeText: "var(--status-error)",
|
||||||
symbol: "!",
|
symbol: "!",
|
||||||
fallbackTitle: "Something went wrong",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +58,7 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AlertDialog: Component = () => {
|
const AlertDialog: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
let primaryButtonRef: HTMLButtonElement | undefined
|
let primaryButtonRef: HTMLButtonElement | undefined
|
||||||
let promptInputRef: HTMLInputElement | undefined
|
let promptInputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
@@ -82,11 +81,25 @@ const AlertDialog: Component = () => {
|
|||||||
{(payload) => {
|
{(payload) => {
|
||||||
const variant = payload.variant ?? "info"
|
const variant = payload.variant ?? "info"
|
||||||
const accent = variantAccent[variant]
|
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 isConfirm = payload.type === "confirm"
|
||||||
const isPrompt = payload.type === "prompt"
|
const isPrompt = payload.type === "prompt"
|
||||||
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
|
const confirmLabel =
|
||||||
const cancelLabel = payload.cancelLabel || "Cancel"
|
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 ?? "")
|
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
|
||||||
|
|
||||||
@@ -127,7 +140,9 @@ const AlertDialog: Component = () => {
|
|||||||
|
|
||||||
<Show when={isPrompt}>
|
<Show when={isPrompt}>
|
||||||
<div class="mt-4">
|
<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
|
<input
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
promptInputRef = el
|
promptInputRef = el
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from "solid-js"
|
import { Component } from "solid-js"
|
||||||
import type { Attachment } from "../types/attachment"
|
import type { Attachment } from "../types/attachment"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface AttachmentChipProps {
|
interface AttachmentChipProps {
|
||||||
attachment: Attachment
|
attachment: Attachment
|
||||||
@@ -7,6 +8,7 @@ interface AttachmentChipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="attachment-chip"
|
class="attachment-chip"
|
||||||
@@ -16,7 +18,7 @@ const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
onClick={props.onRemove}
|
onClick={props.onRemove}
|
||||||
class="attachment-remove"
|
class="attachment-remove"
|
||||||
aria-label="Remove attachment"
|
aria-label={t("attachmentChip.removeAriaLabel")}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
|||||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||||
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
||||||
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface BackgroundProcessOutputDialogProps {
|
interface BackgroundProcessOutputDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,6 +13,7 @@ interface BackgroundProcessOutputDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [output, setOutput] = createSignal("")
|
const [output, setOutput] = createSignal("")
|
||||||
const [outputHtml, setOutputHtml] = createSignal("")
|
const [outputHtml, setOutputHtml] = createSignal("")
|
||||||
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
||||||
@@ -67,7 +69,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setRawOutput("Failed to load output.")
|
setRawOutput(t("backgroundProcessOutputDialog.loadErrorFallback"))
|
||||||
setAnsiEnabled(false)
|
setAnsiEnabled(false)
|
||||||
setOutputHtml("")
|
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">
|
<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 items-start justify-between px-6 py-4 border-b border-base gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<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}>
|
<Show when={props.process}>
|
||||||
<span class="text-xs text-secondary block">
|
<span class="text-xs text-secondary block">
|
||||||
{props.process?.title} · {props.process?.id}
|
{props.process?.title} · {props.process?.id}
|
||||||
@@ -133,16 +135,16 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
|
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
|
||||||
Close
|
{t("backgroundProcessOutputDialog.actions.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-auto p-6">
|
<div class="flex-1 overflow-auto p-6">
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
<p class="text-xs text-secondary">Loading output...</p>
|
<p class="text-xs text-secondary">{t("backgroundProcessOutputDialog.loading")}</p>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!loading()}>
|
<Show when={!loading()}>
|
||||||
<Show when={truncated()}>
|
<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>
|
||||||
<Show
|
<Show
|
||||||
when={ansiEnabled()}
|
when={ansiEnabled()}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Highlighter } from "shiki/bundle/full"
|
|||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const inlineLoadedLanguages = new Set<string>()
|
const inlineLoadedLanguages = new Set<string>()
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface CodeBlockInlineProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
@@ -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>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="copy-text">
|
<span class="copy-text">
|
||||||
<Show when={copied()} fallback="Copy">
|
<Show when={copied()} fallback={t("codeBlockInline.actions.copy")}>
|
||||||
Copied!
|
{t("codeBlockInline.actions.copied")}
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import type { Command } from "../lib/commands"
|
import { resolveResolvable, type Command } from "../lib/commands"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -24,6 +25,7 @@ function buildShortcutString(shortcut: Command["shortcut"]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
||||||
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
|
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 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 CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
||||||
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
||||||
|
|
||||||
@@ -41,18 +64,21 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const filtered = q
|
const filtered = q
|
||||||
? source.filter((cmd) => {
|
? 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 labelMatch = label.toLowerCase().includes(q)
|
||||||
const descMatch = cmd.description.toLowerCase().includes(q)
|
const descMatch = description.toLowerCase().includes(q)
|
||||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(q))
|
||||||
const categoryMatch = cmd.category?.toLowerCase().includes(q)
|
const categoryMatch = category?.toLowerCase().includes(q)
|
||||||
return labelMatch || descMatch || keywordMatch || categoryMatch
|
return labelMatch || descMatch || keywordMatch || categoryMatch
|
||||||
})
|
})
|
||||||
: source
|
: source
|
||||||
|
|
||||||
const groupsMap = new Map<string, Command[]>()
|
const groupsMap = new Map<string, Command[]>()
|
||||||
for (const cmd of filtered) {
|
for (const cmd of filtered) {
|
||||||
const category = cmd.category || "Other"
|
const category = (cmd.category ? resolveResolvable(cmd.category) : undefined) || "Other"
|
||||||
const list = groupsMap.get(category)
|
const list = groupsMap.get(category)
|
||||||
if (list) {
|
if (list) {
|
||||||
list.push(cmd)
|
list.push(cmd)
|
||||||
@@ -189,12 +215,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||||
<Dialog.Content
|
<Dialog.Content
|
||||||
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
|
<Dialog.Title class="sr-only">{t("commandPalette.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
|
<Dialog.Description class="sr-only">{t("commandPalette.description")}</Dialog.Description>
|
||||||
|
|
||||||
<div class="modal-search-container">
|
<div class="modal-search-container">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -214,7 +240,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
setQuery(e.currentTarget.value)
|
setQuery(e.currentTarget.value)
|
||||||
setSelectedCommandId(null)
|
setSelectedCommandId(null)
|
||||||
}}
|
}}
|
||||||
placeholder="Type a command or search..."
|
placeholder={t("commandPalette.searchPlaceholder")}
|
||||||
class="modal-search-input"
|
class="modal-search-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,13 +254,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={orderedCommands().length > 0}
|
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()}>
|
<For each={groupedCommandList()}>
|
||||||
{(group) => (
|
{(group) => (
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<div class="modal-section-header">
|
<div class="modal-section-header">
|
||||||
{group.category}
|
{categoryLabel(group.category)}
|
||||||
</div>
|
</div>
|
||||||
<For each={group.commands}>
|
<For each={group.commands}>
|
||||||
{(command, localIndex) => {
|
{(command, localIndex) => {
|
||||||
@@ -257,10 +283,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="modal-item-label">
|
<div class="modal-item-label">
|
||||||
{typeof command.label === "function" ? command.label() : command.label}
|
{resolveResolvable(command.label)}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-item-description">
|
<div class="modal-item-description">
|
||||||
{command.description}
|
{resolveResolvable(command.description)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={command.shortcut}>
|
<Show when={command.shortcut}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server
|
|||||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
function normalizePathKey(input?: string | null) {
|
function normalizePathKey(input?: string | null) {
|
||||||
if (!input || input === "." || input === "./") {
|
if (!input || input === "." || input === "./") {
|
||||||
@@ -62,6 +63,7 @@ type FolderRow =
|
|||||||
| { type: "folder"; entry: FileSystemEntry }
|
| { type: "folder"; entry: FileSystemEntry }
|
||||||
|
|
||||||
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
|
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
@@ -110,7 +112,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const metadata = await loadDirectory()
|
const metadata = await loadDirectory()
|
||||||
applyMetadata(metadata)
|
applyMetadata(metadata)
|
||||||
} catch (err) {
|
} 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)
|
setError(message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -200,7 +202,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const metadata = await loadDirectory(path)
|
const metadata = await loadDirectory(path)
|
||||||
applyMetadata(metadata)
|
applyMetadata(metadata)
|
||||||
} catch (err) {
|
} 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)
|
setError(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,19 +268,19 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
(await showPromptDialog("Create a new folder in the current directory.", {
|
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||||
title: "New Folder",
|
title: t("directoryBrowser.createFolder.title"),
|
||||||
inputLabel: "Folder name",
|
inputLabel: t("directoryBrowser.createFolder.inputLabel"),
|
||||||
inputPlaceholder: "e.g. my-new-project",
|
inputPlaceholder: t("directoryBrowser.createFolder.inputPlaceholder"),
|
||||||
confirmLabel: "Create",
|
confirmLabel: t("directoryBrowser.createFolder.confirmLabel"),
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: t("directoryBrowser.createFolder.cancelLabel"),
|
||||||
}))?.trim() ?? ""
|
}))?.trim() ?? ""
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
|
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
|
||||||
showAlertDialog("Please enter a single folder name.", {
|
showAlertDialog(t("directoryBrowser.createFolder.invalidNameMessage"), {
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
detail: "Folder names cannot include slashes, '..', or '~'.",
|
detail: t("directoryBrowser.createFolder.invalidNameDetail"),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -297,8 +299,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
|
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
|
||||||
await navigateTo(created.path)
|
await navigateTo(created.path)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unable to create folder"
|
const message = err instanceof Error ? err.message : t("directoryBrowser.createFolder.errorFallback")
|
||||||
showAlertDialog(message, { variant: "error", title: "Unable to create folder" })
|
showAlertDialog(message, { variant: "error", title: t("directoryBrowser.createFolder.errorFallback") })
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingFolder(false)
|
setCreatingFolder(false)
|
||||||
}
|
}
|
||||||
@@ -323,10 +325,10 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<div class="directory-browser-heading">
|
<div class="directory-browser-heading">
|
||||||
<h3 class="directory-browser-title">{props.title}</h3>
|
<h3 class="directory-browser-title">{props.title}</h3>
|
||||||
<p class="directory-browser-description">
|
<p class="directory-browser-description">
|
||||||
{props.description || "Browse folders under the configured workspace root."}
|
{props.description || t("directoryBrowser.defaultDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" />
|
<X class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +337,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<Show when={rootPath()}>
|
<Show when={rootPath()}>
|
||||||
<div class="directory-browser-current">
|
<div class="directory-browser-current">
|
||||||
<div class="directory-browser-current-meta">
|
<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>
|
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="directory-browser-current-actions">
|
<div class="directory-browser-current-actions">
|
||||||
@@ -350,7 +352,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Select Current
|
{t("directoryBrowser.selectCurrent")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -360,7 +362,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
<FolderPlus class="w-4 h-4" />
|
<FolderPlus class="w-4 h-4" />
|
||||||
{creatingFolder() ? "Creating…" : "New Folder"}
|
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +375,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
|
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
|
||||||
<div class="directory-browser-loading">
|
<div class="directory-browser-loading">
|
||||||
<Loader2 class="w-5 h-5 animate-spin" />
|
<Loader2 class="w-5 h-5 animate-spin" />
|
||||||
<span>Loading folders…</span>
|
<span>{t("directoryBrowser.loadingFolders")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,13 +383,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={folderRows().length > 0}
|
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">
|
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
|
||||||
<For each={folderRows()}>
|
<For each={folderRows()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const isFolder = item.type === "folder"
|
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())
|
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
|
||||||
return (
|
return (
|
||||||
<div class="panel-list-item" role="option">
|
<div class="panel-list-item" role="option">
|
||||||
@@ -414,7 +416,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
handleEntrySelect(item.entry)
|
handleEntrySelect(item.entry)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Select
|
{t("directoryBrowser.select")}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from "solid-js"
|
import { Component } from "solid-js"
|
||||||
import { Loader2 } from "lucide-solid"
|
import { Loader2 } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -9,15 +10,19 @@ interface EmptyStateProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState: Component<EmptyStateProps> = (props) => {
|
const EmptyState: Component<EmptyStateProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modifier = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"
|
||||||
|
const shortcut = `${modifier}+N`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
|
<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="max-w-[500px] px-8 py-12 text-center">
|
||||||
<div class="mb-8 flex justify-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>
|
</div>
|
||||||
|
|
||||||
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-3 text-3xl font-semibold text-primary">{t("emptyState.brandTitle")}</h1>
|
||||||
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
|
<p class="mb-8 text-base text-secondary">{t("emptyState.tagline")}</p>
|
||||||
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -28,20 +33,20 @@ const EmptyState: Component<EmptyStateProps> = (props) => {
|
|||||||
{props.isLoading ? (
|
{props.isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 class="h-4 w-4 animate-spin" />
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
Selecting...
|
{t("emptyState.actions.selecting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Select Folder"
|
t("emptyState.actions.selectFolder")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p class="text-sm text-muted">
|
<p class="text-sm text-muted">
|
||||||
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
|
{t("emptyState.keyboardShortcut", { shortcut })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-6 space-y-1 text-sm text-muted">
|
<div class="mt-6 space-y-1 text-sm text-muted">
|
||||||
<p>Examples: ~/projects/my-app</p>
|
<p>{t("emptyState.examples", { example: "~/projects/my-app" })}</p>
|
||||||
<p>You can have multiple instances of the same folder</p>
|
<p>{t("emptyState.multipleInstances")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Component, createSignal, For, Show } from "solid-js"
|
import { Component, createSignal, For, Show } from "solid-js"
|
||||||
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface EnvironmentVariablesEditorProps {
|
interface EnvironmentVariablesEditorProps {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
@@ -54,9 +56,11 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<Globe class="w-4 h-4 icon-muted" />
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,8 +77,8 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
value={key}
|
value={key}
|
||||||
disabled={props.disabled}
|
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"
|
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"
|
placeholder={t("envEditor.fields.name.placeholder")}
|
||||||
title="Variable name (read-only)"
|
title={t("envEditor.fields.name.readOnlyTitle")}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -82,14 +86,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
|
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"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveVariable(key)}
|
onClick={() => handleRemoveVariable(key)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
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" />
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -110,7 +114,7 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
disabled={props.disabled}
|
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"
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -119,14 +123,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
disabled={props.disabled}
|
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"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddVariable}
|
onClick={handleAddVariable}
|
||||||
disabled={props.disabled || !newKey().trim()}
|
disabled={props.disabled || !newKey().trim()}
|
||||||
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
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" />
|
<Plus class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -134,12 +138,12 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
|
|
||||||
<Show when={entries().length === 0}>
|
<Show when={entries().length === 0}>
|
||||||
<div class="text-xs text-muted text-center py-2">
|
<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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="text-xs text-muted mt-2">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { Maximize2, Minimize2 } from "lucide-solid"
|
import { Maximize2, Minimize2 } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface ExpandButtonProps {
|
interface ExpandButtonProps {
|
||||||
expandState: () => "normal" | "expanded"
|
expandState: () => "normal" | "expanded"
|
||||||
@@ -7,6 +8,8 @@ interface ExpandButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ExpandButton(props: ExpandButtonProps) {
|
export default function ExpandButton(props: ExpandButtonProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
const current = props.expandState()
|
const current = props.expandState()
|
||||||
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
|
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
|
||||||
@@ -17,7 +20,7 @@ export default function ExpandButton(props: ExpandButtonProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="prompt-expand-button"
|
class="prompt-expand-button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
aria-label="Toggle chat input height"
|
aria-label={t("expandButton.toggleAriaLabel")}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.expandState() === "normal"}
|
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 type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ interface FileSystemBrowserDialogProps {
|
|||||||
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
||||||
|
|
||||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||||
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
||||||
@@ -135,7 +137,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
setRootPath(metadata.rootPath)
|
setRootPath(metadata.rootPath)
|
||||||
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
||||||
} catch (err) {
|
} 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)
|
setError(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,10 +145,10 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
function describeLoadingPath() {
|
function describeLoadingPath() {
|
||||||
const path = loadingPath()
|
const path = loadingPath()
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return "filesystem"
|
return t("filesystemBrowser.loading.filesystem")
|
||||||
}
|
}
|
||||||
if (path === ".") {
|
if (path === ".") {
|
||||||
return rootPath() || "workspace root"
|
return rootPath() || t("filesystemBrowser.loading.workspaceRoot")
|
||||||
}
|
}
|
||||||
return resolveAbsolutePath(rootPath(), path)
|
return resolveAbsolutePath(rootPath(), path)
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
function handleNavigateTo(path: string) {
|
function handleNavigateTo(path: string) {
|
||||||
void fetchDirectory(path, true).catch((err) => {
|
void fetchDirectory(path, true).catch((err) => {
|
||||||
log.error("Failed to open directory", 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 class="panel-header flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="panel-title">{props.title}</h3>
|
<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()}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||||
<X class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
Close
|
{t("filesystemBrowser.actions.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<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="selector-input-group">
|
||||||
<div class="flex items-center gap-2 px-3 text-muted">
|
<div class="flex items-center gap-2 px-3 text-muted">
|
||||||
<Search class="w-4 h-4" />
|
<Search class="w-4 h-4" />
|
||||||
@@ -301,7 +305,11 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery()}
|
value={searchQuery()}
|
||||||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
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"
|
class="selector-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +319,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<div class="px-4 pb-2">
|
<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 class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
|
||||||
<div>
|
<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>
|
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -319,7 +327,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
class="selector-button selector-button-secondary whitespace-nowrap"
|
class="selector-button selector-button-secondary whitespace-nowrap"
|
||||||
onClick={() => props.onSelect(currentAbsolutePath())}
|
onClick={() => props.onSelect(currentAbsolutePath())}
|
||||||
>
|
>
|
||||||
Select Current
|
{t("filesystemBrowser.currentFolder.selectCurrent")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,7 +344,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Loader2 class="w-4 h-4 animate-spin" />
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
<span>Loading {describeLoadingPath()}…</span>
|
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,16 +353,16 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<Show when={loadingPath()}>
|
<Show when={loadingPath()}>
|
||||||
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
|
<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" />
|
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||||
<span>Loading {describeLoadingPath()}…</span>
|
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={folderRows().length > 0}
|
when={folderRows().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
<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}>
|
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||||
Retry
|
{t("filesystemBrowser.actions.retry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -370,7 +378,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<ArrowUpLeft class="w-4 h-4" />
|
<ArrowUpLeft class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="directory-browser-row-text">
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,7 +420,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
selectEntry()
|
selectEntry()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Select
|
{t("filesystemBrowser.actions.select")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,15 +436,15 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
<kbd class="kbd">↓</kbd>
|
<kbd class="kbd">↓</kbd>
|
||||||
<span>Navigate</span>
|
<span>{t("filesystemBrowser.hints.navigate")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Select</span>
|
<span>{t("filesystemBrowser.hints.select")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Esc</kbd>
|
<kbd class="kbd">Esc</kbd>
|
||||||
<span>Close</span>
|
<span>{t("filesystemBrowser.hints.close")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,4 +456,3 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default FileSystemBrowserDialog
|
export default FileSystemBrowserDialog
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
@@ -9,6 +10,7 @@ import VersionPill from "./version-pill"
|
|||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
import { githubStars } from "../stores/github-stars"
|
import { githubStars } from "../stores/github-stars"
|
||||||
import { formatCompactCount } from "../lib/formatters"
|
import { formatCompactCount } from "../lib/formatters"
|
||||||
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -23,13 +25,27 @@ interface FolderSelectionViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
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 [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
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 folders = () => recentFolders()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
@@ -181,10 +197,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderSelect(path: string) {
|
function handleFolderSelect(path: string) {
|
||||||
@@ -203,7 +219,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
if (nativeDialogsAvailable) {
|
if (nativeDialogsAvailable) {
|
||||||
const fallbackPath = folders()[0]?.path
|
const fallbackPath = folders()[0]?.path
|
||||||
const selected = await openNativeFolderDialog({
|
const selected = await openNativeFolderDialog({
|
||||||
title: "Select Workspace",
|
title: t("folderSelection.dialog.title"),
|
||||||
defaultPath: fallbackPath,
|
defaultPath: fallbackPath,
|
||||||
})
|
})
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -253,6 +269,50 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
class="w-full max-w-5xl 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"}
|
aria-busy={isLoading() ? "true" : "false"}
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
<Show when={props.onOpenRemoteAccess}>
|
<Show when={props.onOpenRemoteAccess}>
|
||||||
<div class="absolute top-4 right-6">
|
<div class="absolute top-4 right-6">
|
||||||
<button
|
<button
|
||||||
@@ -266,7 +326,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
<div class="mb-6 text-center shrink-0">
|
<div class="mb-6 text-center shrink-0">
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<div class="mt-3 flex justify-center gap-2">
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
@@ -275,8 +335,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
aria-label="CodeNomad GitHub"
|
aria-label={t("folderSelection.links.github")}
|
||||||
title="CodeNomad GitHub"
|
title={t("folderSelection.links.github")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
@@ -289,8 +349,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
aria-label="CodeNomad GitHub Stars"
|
aria-label={t("folderSelection.links.githubStars")}
|
||||||
title="CodeNomad GitHub Stars"
|
title={t("folderSelection.links.githubStars")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
@@ -306,8 +366,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
aria-label="CodeNomad Discord"
|
aria-label={t("folderSelection.links.discord")}
|
||||||
title="CodeNomad Discord"
|
title={t("folderSelection.links.discord")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openExternalLink(
|
openExternalLink(
|
||||||
@@ -318,7 +378,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<DiscordSymbolIcon class="w-4 h-4" />
|
<DiscordSymbolIcon class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p>
|
<p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 gap-4">
|
||||||
@@ -332,16 +392,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="panel-empty-state-icon">
|
<div class="panel-empty-state-icon">
|
||||||
<Clock class="w-12 h-12 mx-auto" />
|
<Clock class="w-12 h-12 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="panel flex flex-col flex-1 min-h-0">
|
<div class="panel flex flex-col flex-1 min-h-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2 class="panel-title">Recent Folders</h2>
|
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||||
<p class="panel-subtitle">
|
<p class="panel-subtitle">
|
||||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
{t(
|
||||||
|
folders().length === 1
|
||||||
|
? "folderSelection.recent.subtitle.one"
|
||||||
|
: "folderSelection.recent.subtitle.other",
|
||||||
|
{ count: folders().length },
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -393,7 +458,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
onClick={(e) => handleRemove(folder.path, e)}
|
onClick={(e) => handleRemove(folder.path, e)}
|
||||||
disabled={isLoading()}
|
disabled={isLoading()}
|
||||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
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"
|
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" />
|
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||||
</button>
|
</button>
|
||||||
@@ -411,8 +476,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
<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 shrink-0">
|
||||||
<div class="panel-header hidden sm:block">
|
<div class="panel-header hidden sm:block">
|
||||||
<h2 class="panel-title">Browse for Folder</h2>
|
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
@@ -424,7 +489,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<FolderPlus class="w-4 h-4" />
|
<FolderPlus class="w-4 h-4" />
|
||||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
<span>
|
||||||
|
{props.isLoading
|
||||||
|
? t("folderSelection.browse.buttonOpening")
|
||||||
|
: t("folderSelection.browse.button")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
@@ -435,7 +504,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Settings class="w-4 h-4 icon-muted" />
|
<Settings class="w-4 h-4 icon-muted" />
|
||||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||||
</button>
|
</button>
|
||||||
@@ -457,20 +526,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
<kbd class="kbd">↓</kbd>
|
<kbd class="kbd">↓</kbd>
|
||||||
<span>Navigate</span>
|
<span>{t("folderSelection.hints.navigate")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Select</span>
|
<span>{t("folderSelection.hints.select")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Del</kbd>
|
<kbd class="kbd">Del</kbd>
|
||||||
<span>Remove</span>
|
<span>{t("folderSelection.hints.remove")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Kbd shortcut="cmd+n" />
|
<Kbd shortcut="cmd+n" />
|
||||||
<span>Browse</span>
|
<span>{t("folderSelection.hints.browse")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,8 +549,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="folder-loading-overlay">
|
<div class="folder-loading-overlay">
|
||||||
<div class="folder-loading-indicator">
|
<div class="folder-loading-indicator">
|
||||||
<div class="spinner" />
|
<div class="spinner" />
|
||||||
<p class="folder-loading-text">Starting instance…</p>
|
<p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
|
||||||
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
|
<p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -497,8 +566,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
<DirectoryBrowserDialog
|
<DirectoryBrowserDialog
|
||||||
open={isFolderBrowserOpen()}
|
open={isFolderBrowserOpen()}
|
||||||
title="Select Workspace"
|
title={t("folderSelection.dialog.title")}
|
||||||
description="Select workspace to start coding."
|
description={t("folderSelection.dialog.description")}
|
||||||
onClose={() => setIsFolderBrowserOpen(false)}
|
onClose={() => setIsFolderBrowserOpen(false)}
|
||||||
onSelect={handleBrowserSelect}
|
onSelect={handleBrowserSelect}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, c
|
|||||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import InstanceInfo from "./instance-info"
|
import InstanceInfo from "./instance-info"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InfoViewProps {
|
interface InfoViewProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -10,6 +11,7 @@ interface InfoViewProps {
|
|||||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||||
|
|
||||||
const InfoView: Component<InfoViewProps> = (props) => {
|
const InfoView: Component<InfoViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
let scrollRef: HTMLDivElement | undefined
|
let scrollRef: HTMLDivElement | undefined
|
||||||
const savedState = logsScrollState.get(props.instanceId)
|
const savedState = logsScrollState.get(props.instanceId)
|
||||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
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="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<div class="log-header">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("infoView.logs.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||||
Hide server logs
|
{t("infoView.logs.actions.hide")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,17 +118,17 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="log-paused-state">
|
<div class="log-paused-state">
|
||||||
<p class="log-paused-title">Server logs are paused</p>
|
<p class="log-paused-title">{t("infoView.logs.paused.title")}</p>
|
||||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
<p class="log-paused-description">{t("infoView.logs.paused.description")}</p>
|
||||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("infoView.logs.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={logs().length > 0}
|
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()}>
|
<For each={logs()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
@@ -148,7 +150,7 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||||||
class="scroll-to-bottom"
|
class="scroll-to-bottom"
|
||||||
>
|
>
|
||||||
<ChevronDown class="w-4 h-4" />
|
<ChevronDown class="w-4 h-4" />
|
||||||
Scroll to bottom
|
{t("infoView.logs.scrollToBottom")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceDisconnectedModalProps {
|
interface InstanceDisconnectedModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -8,8 +9,10 @@ interface InstanceDisconnectedModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
|
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
|
||||||
const folderLabel = props.folder || "this workspace"
|
const { t } = useI18n()
|
||||||
const reasonLabel = props.reason || "The server stopped responding"
|
|
||||||
|
const folderLabel = () => props.folder || t("instanceDisconnected.folderFallback")
|
||||||
|
const reasonLabel = () => props.reason || t("instanceDisconnected.reasonFallback")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} modal>
|
<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">
|
<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">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<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">
|
<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>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
|
<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="font-medium text-primary">{t("instanceDisconnected.details.title")}</p>
|
||||||
<p class="mt-2 text-secondary">{reasonLabel}</p>
|
<p class="mt-2 text-secondary">{reasonLabel()}</p>
|
||||||
{props.folder && (
|
{props.folder && (
|
||||||
<p class="mt-2 text-secondary">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
|
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
|
||||||
Close Instance
|
{t("instanceDisconnected.actions.closeInstance")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, For, Show, createMemo } from "solid-js"
|
|||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
import InstanceServiceStatus from "./instance-service-status"
|
import InstanceServiceStatus from "./instance-service-status"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceInfoProps {
|
interface InstanceInfoProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
@@ -9,6 +10,7 @@ interface InstanceInfoProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const metadataContext = useOptionalInstanceMetadataContext()
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
||||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||||
@@ -26,11 +28,11 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2 class="panel-title">Instance Information</h2>
|
<h2 class="panel-title">{t("instanceInfo.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body space-y-3">
|
<div class="panel-body space-y-3">
|
||||||
<div>
|
<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">
|
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
{currentInstance().folder}
|
{currentInstance().folder}
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +43,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
Project
|
{t("instanceInfo.labels.project")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||||
{project().id}
|
{project().id}
|
||||||
@@ -51,7 +53,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={project().vcs}>
|
<Show when={project().vcs}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
Version Control
|
{t("instanceInfo.labels.versionControl")}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs text-primary">
|
<div class="flex items-center gap-2 text-xs text-primary">
|
||||||
<svg
|
<svg
|
||||||
@@ -73,7 +75,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={binaryVersion()}>
|
<Show when={binaryVersion()}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
OpenCode Version
|
{t("instanceInfo.labels.opencodeVersion")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
v{binaryVersion()}
|
v{binaryVersion()}
|
||||||
@@ -84,7 +86,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={currentInstance().binaryPath}>
|
<Show when={currentInstance().binaryPath}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
Binary Path
|
{t("instanceInfo.labels.binaryPath")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
{currentInstance().binaryPath}
|
{currentInstance().binaryPath}
|
||||||
@@ -95,7 +97,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={environmentEntries().length > 0}>
|
<Show when={environmentEntries().length > 0}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
<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>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<For each={environmentEntries()}>
|
<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"
|
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>
|
</svg>
|
||||||
Loading...
|
{t("instanceInfo.loading")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div>
|
<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="space-y-1 text-xs">
|
||||||
<div class="flex justify-between items-center">
|
<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>
|
<span class="text-primary font-mono">{currentInstance().port}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<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>
|
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<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}`}>
|
<span class={`status-badge ${currentInstance().status}`}>
|
||||||
<div
|
<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" : ""}`}
|
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 Switch from "@suid/material/Switch"
|
||||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
@@ -42,6 +43,7 @@ function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const metadataContext = useOptionalInstanceMetadataContext()
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
const instance = metadataContext?.instance ?? (() => {
|
const instance = metadataContext?.instance ?? (() => {
|
||||||
if (props.initialInstance) {
|
if (props.initialInstance) {
|
||||||
@@ -112,12 +114,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
<section class="space-y-1.5">
|
<section class="space-y-1.5">
|
||||||
<Show when={showHeadings()}>
|
<Show when={showHeadings()}>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
LSP Servers
|
{t("instanceServiceStatus.sections.lsp")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isLspLoading() && lspServers().length > 0}
|
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">
|
<div class="space-y-1.5">
|
||||||
<For each={lspServers()}>
|
<For each={lspServers()}>
|
||||||
@@ -132,7 +134,11 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
<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"}`} />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,12 +153,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
<section class="space-y-1.5">
|
<section class="space-y-1.5">
|
||||||
<Show when={showHeadings()}>
|
<Show when={showHeadings()}>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
MCP Servers
|
{t("instanceServiceStatus.sections.mcp")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isMcpLoading() && mcpServers().length > 0}
|
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">
|
<div class="space-y-1.5">
|
||||||
<For each={mcpServers()}>
|
<For each={mcpServers()}>
|
||||||
@@ -192,7 +198,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
disabled={switchDisabled()}
|
disabled={switchDisabled()}
|
||||||
color="success"
|
color="success"
|
||||||
size="small"
|
size="small"
|
||||||
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
|
inputProps={{ "aria-label": t("instanceServiceStatus.mcp.toggleAriaLabel", { name: server.name }) }}
|
||||||
onChange={(_, checked) => {
|
onChange={(_, checked) => {
|
||||||
if (switchDisabled()) return
|
if (switchDisabled()) return
|
||||||
void toggleMcpServer(server.name, Boolean(checked))
|
void toggleMcpServer(server.name, Boolean(checked))
|
||||||
@@ -222,12 +228,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
<section class="space-y-1.5">
|
<section class="space-y-1.5">
|
||||||
<Show when={showHeadings()}>
|
<Show when={showHeadings()}>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
Plugins
|
{t("instanceServiceStatus.sections.plugins")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isPluginsLoading() && plugins().length > 0}
|
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">
|
<div class="space-y-1.5">
|
||||||
<For each={plugins()}>
|
<For each={plugins()}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, createMemo } from "solid-js"
|
|||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
|
||||||
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
|
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceTabProps {
|
interface InstanceTabProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
@@ -27,6 +28,7 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
|
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
|
||||||
const statusClassName = createMemo(() => {
|
const statusClassName = createMemo(() => {
|
||||||
const status = aggregatedStatus()
|
const status = aggregatedStatus()
|
||||||
@@ -35,13 +37,13 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
const statusTitle = createMemo(() => {
|
const statusTitle = createMemo(() => {
|
||||||
switch (aggregatedStatus()) {
|
switch (aggregatedStatus()) {
|
||||||
case "permission":
|
case "permission":
|
||||||
return "Waiting on permission"
|
return t("instanceTab.status.permission")
|
||||||
case "compacting":
|
case "compacting":
|
||||||
return "Compacting"
|
return t("instanceTab.status.compacting")
|
||||||
case "working":
|
case "working":
|
||||||
return "Working"
|
return t("instanceTab.status.working")
|
||||||
default:
|
default:
|
||||||
return "Idle"
|
return t("instanceTab.status.idle")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
<span
|
<span
|
||||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||||
title={statusTitle()}
|
title={statusTitle()}
|
||||||
aria-label={`Instance status: ${statusTitle()}`}
|
aria-label={t("instanceTab.status.ariaLabel", { status: statusTitle() })}
|
||||||
>
|
>
|
||||||
{aggregatedStatus() === "permission" ? (
|
{aggregatedStatus() === "permission" ? (
|
||||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
@@ -77,7 +79,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Close instance"
|
aria-label={t("instanceTab.actions.close.ariaLabel")}
|
||||||
>
|
>
|
||||||
<X class="w-3 h-3" />
|
<X class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import InstanceTab from "./instance-tab"
|
|||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp } from "lucide-solid"
|
import { Plus, MonitorUp } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -15,6 +16,7 @@ interface InstanceTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
return (
|
return (
|
||||||
<div class="tab-bar tab-bar-instance">
|
<div class="tab-bar tab-bar-instance">
|
||||||
<div class="tab-container" role="tablist">
|
<div class="tab-container" role="tablist">
|
||||||
@@ -34,8 +36,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
class="new-tab-button"
|
class="new-tab-button"
|
||||||
onClick={props.onNew}
|
onClick={props.onNew}
|
||||||
title="New instance (Cmd/Ctrl+N)"
|
title={t("instanceTabs.new.title")}
|
||||||
aria-label="New instance"
|
aria-label={t("instanceTabs.new.ariaLabel")}
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -54,8 +56,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
class="new-tab-button tab-remote-button"
|
class="new-tab-button tab-remote-button"
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
title="Remote connect"
|
title={t("instanceTabs.remote.title")}
|
||||||
aria-label="Remote connect"
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
>
|
>
|
||||||
<MonitorUp class="w-4 h-4" />
|
<MonitorUp class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SessionRenameDialog from "./session-rename-dialog"
|
|||||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||||
import { isMac } from "../lib/keyboard-utils"
|
import { isMac } from "../lib/keyboard-utils"
|
||||||
import { showToastNotification } from "../lib/notifications"
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface InstanceWelcomeViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [isCreating, setIsCreating] = createSignal(false)
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||||
@@ -47,7 +49,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
ctrl: !isMac(),
|
ctrl: !isMac(),
|
||||||
},
|
},
|
||||||
handler: () => {},
|
handler: () => {},
|
||||||
description: "New Session",
|
description: t("instanceWelcome.shortcuts.newSession"),
|
||||||
context: "global",
|
context: "global",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -248,10 +250,10 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(timestamp: number): string {
|
function formatTimestamp(timestamp: number): string {
|
||||||
@@ -291,7 +293,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
setRenameTarget(null)
|
setRenameTarget(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to rename session:", 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 {
|
} finally {
|
||||||
setIsRenaming(false)
|
setIsRenaming(false)
|
||||||
}
|
}
|
||||||
@@ -333,11 +335,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
<p class="panel-empty-state-title">{t("instanceWelcome.empty.title")}</p>
|
||||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
<p class="panel-empty-state-description">{t("instanceWelcome.empty.description")}</p>
|
||||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||||
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
||||||
View Instance Info
|
{t("instanceWelcome.actions.viewInstanceInfo")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,8 +349,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="panel-empty-state-icon">
|
<div class="panel-empty-state-icon">
|
||||||
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">Loading Sessions</p>
|
<p class="panel-empty-state-title">{t("instanceWelcome.loading.title")}</p>
|
||||||
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
|
<p class="panel-empty-state-description">{t("instanceWelcome.loading.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -357,9 +359,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="panel-title">Resume Session</h2>
|
<h2 class="panel-title">{t("instanceWelcome.resume.title")}</h2>
|
||||||
<p class="panel-subtitle">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||||
@@ -368,7 +372,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
class="button-tertiary lg:hidden flex-shrink-0"
|
class="button-tertiary lg:hidden flex-shrink-0"
|
||||||
onClick={openInstanceInfoOverlay}
|
onClick={openInstanceInfoOverlay}
|
||||||
>
|
>
|
||||||
View Instance Info
|
{t("instanceWelcome.actions.viewInstanceInfo")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,7 +408,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
"text-accent": isFocused(),
|
"text-accent": isFocused(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{session.title || "Untitled Session"}
|
{session.title || t("instanceWelcome.session.untitled")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||||
@@ -421,7 +425,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -433,7 +437,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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)}
|
disabled={isSessionDeleting(session.id)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -470,8 +474,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
|
|
||||||
<div class="panel flex-shrink-0">
|
<div class="panel flex-shrink-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2 class="panel-title">Start New Session</h2>
|
<h2 class="panel-title">{t("instanceWelcome.new.title")}</h2>
|
||||||
<p class="panel-subtitle">We’ll reuse your last agent/model automatically</p>
|
<p class="panel-subtitle">{t("instanceWelcome.new.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="space-y-3">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<span>Create Session</span>
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
@@ -524,7 +528,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
||||||
Close
|
{t("instanceWelcome.overlay.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
<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">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
<kbd class="kbd">↓</kbd>
|
<kbd class="kbd">↓</kbd>
|
||||||
<span>Navigate</span>
|
<span>{t("instanceWelcome.hints.navigate")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">PgUp</kbd>
|
<kbd class="kbd">PgUp</kbd>
|
||||||
<kbd class="kbd">PgDn</kbd>
|
<kbd class="kbd">PgDn</kbd>
|
||||||
<span>Jump</span>
|
<span>{t("instanceWelcome.hints.jump")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Home</kbd>
|
<kbd class="kbd">Home</kbd>
|
||||||
<kbd class="kbd">End</kbd>
|
<kbd class="kbd">End</kbd>
|
||||||
<span>First/Last</span>
|
<span>{t("instanceWelcome.hints.firstLast")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Resume</span>
|
<span>{t("instanceWelcome.hints.resume")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Del</kbd>
|
<kbd class="kbd">Del</kbd>
|
||||||
<span>Delete</span>
|
<span>{t("instanceWelcome.hints.delete")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { getLogger } from "../../lib/logger"
|
|||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
||||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
import {
|
import {
|
||||||
SESSION_SIDEBAR_EVENT,
|
SESSION_SIDEBAR_EVENT,
|
||||||
type SessionSidebarRequestAction,
|
type SessionSidebarRequestAction,
|
||||||
@@ -87,9 +88,9 @@ interface InstanceShellProps {
|
|||||||
tabBarOffset: number
|
tabBarOffset: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
|
||||||
const MIN_SESSION_SIDEBAR_WIDTH = 220
|
const MIN_SESSION_SIDEBAR_WIDTH = 220
|
||||||
const MAX_SESSION_SIDEBAR_WIDTH = 360
|
const MAX_SESSION_SIDEBAR_WIDTH = 400
|
||||||
const RIGHT_DRAWER_WIDTH = 260
|
const RIGHT_DRAWER_WIDTH = 260
|
||||||
const MIN_RIGHT_DRAWER_WIDTH = 200
|
const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||||
const MAX_RIGHT_DRAWER_WIDTH = 380
|
const MAX_RIGHT_DRAWER_WIDTH = 380
|
||||||
@@ -121,6 +122,8 @@ function persistPinState(side: "left" | "right", value: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
|
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
|
||||||
const [leftPinned, setLeftPinned] = createSignal(true)
|
const [leftPinned, setLeftPinned] = createSignal(true)
|
||||||
@@ -357,6 +360,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
return "disconnected"
|
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 = () => {
|
const handleCommandPaletteClick = () => {
|
||||||
showCommandPalette(props.instance.id)
|
showCommandPalette(props.instance.id)
|
||||||
}
|
}
|
||||||
@@ -716,16 +727,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const leftAppBarButtonLabel = () => {
|
const leftAppBarButtonLabel = () => {
|
||||||
const state = leftDrawerState()
|
const state = leftDrawerState()
|
||||||
if (state === "pinned") return "Left drawer pinned"
|
if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned")
|
||||||
if (state === "floating-closed") return "Open left drawer"
|
if (state === "floating-closed") return t("instanceShell.leftDrawer.toggle.open")
|
||||||
return "Close left drawer"
|
return t("instanceShell.leftDrawer.toggle.close")
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightAppBarButtonLabel = () => {
|
const rightAppBarButtonLabel = () => {
|
||||||
const state = rightDrawerState()
|
const state = rightDrawerState()
|
||||||
if (state === "pinned") return "Right drawer pinned"
|
if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned")
|
||||||
if (state === "floating-closed") return "Open right drawer"
|
if (state === "floating-closed") return t("instanceShell.rightDrawer.toggle.open")
|
||||||
return "Close right drawer"
|
return t("instanceShell.rightDrawer.toggle.close")
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftAppBarButtonIcon = () => {
|
const leftAppBarButtonIcon = () => {
|
||||||
@@ -855,7 +866,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
|
<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 items-start justify-between gap-2 px-4 py-3 border-b border-base">
|
||||||
<div class="flex flex-col gap-1">
|
<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">
|
<div class="session-sidebar-shortcuts">
|
||||||
<Show when={keyboardShortcuts().length}>
|
<Show when={keyboardShortcuts().length}>
|
||||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||||
@@ -866,8 +879,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
aria-label="Instance Info"
|
aria-label={t("instanceShell.leftPanel.instanceInfo")}
|
||||||
title="Instance Info"
|
title={t("instanceShell.leftPanel.instanceInfo")}
|
||||||
onClick={() => handleSessionSelect("info")}
|
onClick={() => handleSessionSelect("info")}
|
||||||
>
|
>
|
||||||
<InfoOutlinedIcon fontSize="small" />
|
<InfoOutlinedIcon fontSize="small" />
|
||||||
@@ -876,7 +889,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="inherit"
|
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())}
|
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
||||||
>
|
>
|
||||||
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
@@ -923,6 +936,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -935,19 +954,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const renderPlanSectionContent = () => {
|
const renderPlanSectionContent = () => {
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!sessionId || sessionId === "info") {
|
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()
|
const todoState = latestTodoState()
|
||||||
if (!todoState) {
|
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 renderBackgroundProcesses = () => {
|
||||||
const processes = backgroundProcessList()
|
const processes = backgroundProcessList()
|
||||||
if (processes.length === 0) {
|
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 (
|
return (
|
||||||
@@ -958,9 +977,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="text-xs font-semibold text-primary">{process.title}</span>
|
<span class="text-xs font-semibold text-primary">{process.title}</span>
|
||||||
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
|
<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"}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -969,8 +992,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
onClick={() => openBackgroundOutput(process)}
|
onClick={() => openBackgroundOutput(process)}
|
||||||
aria-label="Output"
|
aria-label={t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
title="Output"
|
title={t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
>
|
>
|
||||||
<TerminalSquare class="h-4 w-4" />
|
<TerminalSquare class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -979,8 +1002,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
disabled={process.status !== "running"}
|
disabled={process.status !== "running"}
|
||||||
onClick={() => stopBackgroundProcess(process.id)}
|
onClick={() => stopBackgroundProcess(process.id)}
|
||||||
aria-label="Stop"
|
aria-label={t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
title="Stop"
|
title={t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
>
|
>
|
||||||
<XOctagon class="h-4 w-4" />
|
<XOctagon class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -988,8 +1011,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
onClick={() => terminateBackgroundProcess(process.id)}
|
onClick={() => terminateBackgroundProcess(process.id)}
|
||||||
aria-label="Terminate"
|
aria-label={t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
title="Terminate"
|
title={t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4" />
|
<Trash2 class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1004,17 +1027,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
id: "plan",
|
id: "plan",
|
||||||
label: "Plan",
|
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||||
render: renderPlanSectionContent,
|
render: renderPlanSectionContent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "background-processes",
|
id: "background-processes",
|
||||||
label: "Background Shells",
|
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||||
render: renderBackgroundProcesses,
|
render: renderBackgroundProcesses,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mcp",
|
id: "mcp",
|
||||||
label: "MCP Servers",
|
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -1026,7 +1049,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lsp",
|
id: "lsp",
|
||||||
label: "LSP Servers",
|
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -1038,7 +1061,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "plugins",
|
id: "plugins",
|
||||||
label: "Plugins",
|
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -1066,14 +1089,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
|
<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">
|
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
|
||||||
Status Panel
|
{t("instanceShell.rightPanel.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={!isPhoneLayout()}>
|
<Show when={!isPhoneLayout()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="inherit"
|
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())}
|
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
||||||
>
|
>
|
||||||
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
@@ -1097,7 +1120,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Accordion.Header>
|
<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">
|
<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
|
<ChevronDown
|
||||||
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
|
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
|
||||||
/>
|
/>
|
||||||
@@ -1274,17 +1297,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
class="connection-status-button px-2 py-0.5 text-xs"
|
||||||
onClick={handleCommandPaletteClick}
|
onClick={handleCommandPaletteClick}
|
||||||
aria-label="Open command palette"
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
>
|
>
|
||||||
Command Palette
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`status-indicator ${connectionStatusClass()}`}
|
class={`status-indicator ${connectionStatusClass()}`}
|
||||||
aria-label={`Connection ${connectionStatus()}`}
|
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
|
||||||
>
|
>
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
</span>
|
</span>
|
||||||
@@ -1307,11 +1330,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
<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">
|
<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-primary/70">
|
||||||
|
{t("instanceShell.metrics.usedLabel")}
|
||||||
|
</span>
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<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-primary/70">
|
||||||
|
{t("instanceShell.metrics.availableLabel")}
|
||||||
|
</span>
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1333,11 +1360,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
<Show when={!showingInfoView()}>
|
<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">
|
<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-primary/70">
|
||||||
|
{t("instanceShell.metrics.usedLabel")}
|
||||||
|
</span>
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<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-primary/70">
|
||||||
|
{t("instanceShell.metrics.availableLabel")}
|
||||||
|
</span>
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1353,10 +1384,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
class="connection-status-button px-2 py-0.5 text-xs"
|
||||||
onClick={handleCommandPaletteClick}
|
onClick={handleCommandPaletteClick}
|
||||||
aria-label="Open command palette"
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
>
|
>
|
||||||
Command Palette
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
@@ -1371,19 +1402,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<Show when={connectionStatus() === "connected"}>
|
<Show when={connectionStatus() === "connected"}>
|
||||||
<span class="status-indicator connected">
|
<span class="status-indicator connected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connected</span>
|
<span class="status-text">{t("instanceShell.connection.connected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={connectionStatus() === "connecting"}>
|
<Show when={connectionStatus() === "connecting"}>
|
||||||
<span class="status-indicator connecting">
|
<span class="status-indicator connecting">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connecting...</span>
|
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||||
<span class="status-indicator disconnected">
|
<span class="status-indicator disconnected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Disconnected</span>
|
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -1419,8 +1450,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="flex items-center justify-center h-full">
|
||||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||||
<p class="mb-2">No session selected</p>
|
<p class="mb-2">{t("instanceShell.empty.title")}</p>
|
||||||
<p class="text-sm">Select a session to view messages</p>
|
<p class="text-sm">{t("instanceShell.empty.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface LogsViewProps {
|
interface LogsViewProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -9,6 +10,7 @@ interface LogsViewProps {
|
|||||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||||
|
|
||||||
const LogsView: Component<LogsViewProps> = (props) => {
|
const LogsView: Component<LogsViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
let scrollRef: HTMLDivElement | undefined
|
let scrollRef: HTMLDivElement | undefined
|
||||||
const savedState = logsScrollState.get(props.instanceId)
|
const savedState = logsScrollState.get(props.instanceId)
|
||||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||||
@@ -83,18 +85,18 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="log-container">
|
<div class="log-container">
|
||||||
<div class="log-header">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("logsView.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||||
Hide server logs
|
{t("logsView.actions.hide")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +105,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
|
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
|
||||||
<div class="env-vars-container">
|
<div class="env-vars-container">
|
||||||
<div class="env-vars-title">
|
<div class="env-vars-title">
|
||||||
Environment Variables ({Object.keys(instance()?.environmentVariables!).length})
|
{t("logsView.envVars.title", { count: Object.keys(instance()?.environmentVariables!).length })}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<For each={Object.entries(instance()?.environmentVariables!)}>
|
<For each={Object.entries(instance()?.environmentVariables!)}>
|
||||||
@@ -130,17 +132,17 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="log-paused-state">
|
<div class="log-paused-state">
|
||||||
<p class="log-paused-title">Server logs are paused</p>
|
<p class="log-paused-title">{t("logsView.paused.title")}</p>
|
||||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
<p class="log-paused-description">{t("logsView.paused.description")}</p>
|
||||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("logsView.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={logs().length > 0}
|
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()}>
|
<For each={logs()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
@@ -160,7 +162,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
class="scroll-to-bottom"
|
class="scroll-to-bottom"
|
||||||
>
|
>
|
||||||
<ChevronDown class="w-4 h-4" />
|
<ChevronDown class="w-4 h-4" />
|
||||||
Scroll to bottom
|
{t("logsView.scrollToBottom")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
|||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ interface MarkdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown(props: MarkdownProps) {
|
export function Markdown(props: MarkdownProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestedText = ""
|
let latestRequestedText = ""
|
||||||
@@ -145,14 +147,14 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const copyText = copyButton.querySelector(".copy-text")
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
if (copyText) {
|
if (copyText) {
|
||||||
if (success) {
|
if (success) {
|
||||||
copyText.textContent = "Copied!"
|
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyText.textContent = "Copy"
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} else {
|
} else {
|
||||||
copyText.textContent = "Failed"
|
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyText.textContent = "Copy"
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { messageStoreBus } from "../stores/message-v2/bus"
|
|||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const TOOL_ICON = "🔧"
|
const TOOL_ICON = "🔧"
|
||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
@@ -171,21 +172,212 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
|||||||
interface ContentDisplayItem {
|
interface ContentDisplayItem {
|
||||||
type: "content"
|
type: "content"
|
||||||
key: string
|
key: string
|
||||||
record: MessageRecord
|
messageId: string
|
||||||
parts: ClientPart[]
|
startPartId: string
|
||||||
messageInfo?: MessageInfo
|
|
||||||
isQueued: boolean
|
|
||||||
showAgentMeta?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolDisplayItem {
|
interface ToolDisplayItem {
|
||||||
type: "tool"
|
type: "tool"
|
||||||
key: string
|
key: string
|
||||||
toolPart: ToolCallPart
|
|
||||||
messageInfo?: MessageInfo
|
|
||||||
messageId: string
|
messageId: string
|
||||||
messageVersion: number
|
partId: string
|
||||||
partVersion: number
|
}
|
||||||
|
|
||||||
|
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 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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Show when={taskSessionId()}>
|
||||||
|
<button
|
||||||
|
class="tool-call-header-button"
|
||||||
|
type="button"
|
||||||
|
disabled={!taskLocation()}
|
||||||
|
onClick={handleGoToTaskSession}
|
||||||
|
title={!taskLocation() ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
|
||||||
|
>
|
||||||
|
{t("messageBlock.tool.goToSession.label")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</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 {
|
interface StepDisplayItem {
|
||||||
@@ -236,6 +428,7 @@ interface MessageBlockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageBlock(props: MessageBlockProps) {
|
export default function MessageBlock(props: MessageBlockProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
@@ -270,7 +463,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const items: MessageBlockItem[] = []
|
const items: MessageBlockItem[] = []
|
||||||
const blockContentKeys: string[] = []
|
const blockContentKeys: string[] = []
|
||||||
const blockToolKeys: string[] = []
|
const blockToolKeys: string[] = []
|
||||||
let segmentIndex = 0
|
|
||||||
let pendingParts: ClientPart[] = []
|
let pendingParts: ClientPart[] = []
|
||||||
let agentMetaAttached = current.role !== "assistant"
|
let agentMetaAttached = current.role !== "assistant"
|
||||||
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
|
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
|
||||||
@@ -278,34 +470,28 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
|
|
||||||
const flushContent = () => {
|
const flushContent = () => {
|
||||||
if (pendingParts.length === 0) return
|
if (pendingParts.length === 0) return
|
||||||
const segmentKey = `${current.id}:segment:${segmentIndex}`
|
const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : ""
|
||||||
segmentIndex += 1
|
if (!startPartId) {
|
||||||
const shouldShowAgentMeta =
|
pendingParts = []
|
||||||
current.role === "assistant" &&
|
return
|
||||||
!agentMetaAttached &&
|
}
|
||||||
pendingParts.some((part) => partHasRenderableText(part))
|
|
||||||
|
if (!agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part))) {
|
||||||
|
agentMetaAttached = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentKey = `${current.id}:content:${startPartId}`
|
||||||
let cached = sessionCache.messageItems.get(segmentKey)
|
let cached = sessionCache.messageItems.get(segmentKey)
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
cached = {
|
cached = {
|
||||||
type: "content",
|
type: "content",
|
||||||
key: segmentKey,
|
key: segmentKey,
|
||||||
record: current,
|
messageId: current.id,
|
||||||
parts: pendingParts.slice(),
|
startPartId,
|
||||||
messageInfo: info,
|
|
||||||
isQueued,
|
|
||||||
showAgentMeta: shouldShowAgentMeta,
|
|
||||||
}
|
}
|
||||||
sessionCache.messageItems.set(segmentKey, cached)
|
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)
|
items.push(cached)
|
||||||
blockContentKeys.push(segmentKey)
|
blockContentKeys.push(segmentKey)
|
||||||
lastAccentColor = defaultAccentColor
|
lastAccentColor = defaultAccentColor
|
||||||
@@ -315,28 +501,26 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
orderedParts.forEach((part, partIndex) => {
|
orderedParts.forEach((part, partIndex) => {
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
flushContent()
|
flushContent()
|
||||||
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
|
const partId = part.id
|
||||||
const messageVersion = current.revision
|
if (!partId) {
|
||||||
const key = `${current.id}:${part.id ?? partIndex}`
|
// 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)
|
let toolItem = sessionCache.toolItems.get(key)
|
||||||
if (!toolItem) {
|
if (!toolItem) {
|
||||||
toolItem = {
|
toolItem = {
|
||||||
type: "tool",
|
type: "tool",
|
||||||
key,
|
key,
|
||||||
toolPart: part as ToolCallPart,
|
|
||||||
messageInfo: info,
|
|
||||||
messageId: current.id,
|
messageId: current.id,
|
||||||
messageVersion,
|
partId,
|
||||||
partVersion,
|
|
||||||
}
|
}
|
||||||
sessionCache.toolItems.set(key, toolItem)
|
sessionCache.toolItems.set(key, toolItem)
|
||||||
} else {
|
} else {
|
||||||
toolItem.key = key
|
toolItem.key = key
|
||||||
toolItem.toolPart = part as ToolCallPart
|
|
||||||
toolItem.messageInfo = info
|
|
||||||
toolItem.messageId = current.id
|
toolItem.messageId = current.id
|
||||||
toolItem.messageVersion = messageVersion
|
toolItem.partId = partId
|
||||||
toolItem.partVersion = partVersion
|
|
||||||
}
|
}
|
||||||
items.push(toolItem)
|
items.push(toolItem)
|
||||||
blockToolKeys.push(key)
|
blockToolKeys.push(key)
|
||||||
@@ -425,21 +609,21 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={block()} keyed>
|
<Show when={block()}>
|
||||||
{(resolvedBlock) => (
|
{(resolvedBlock) => (
|
||||||
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
|
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
|
||||||
<For each={resolvedBlock.items}>
|
<For each={resolvedBlock().items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={item.type === "content"}>
|
<Match when={item.type === "content"}>
|
||||||
<MessageItem
|
<MessageContentItem
|
||||||
record={(item as ContentDisplayItem).record}
|
|
||||||
messageInfo={(item as ContentDisplayItem).messageInfo}
|
|
||||||
parts={(item as ContentDisplayItem).parts}
|
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
isQueued={(item as ContentDisplayItem).isQueued}
|
store={props.store}
|
||||||
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
messageId={(item as ContentDisplayItem).messageId}
|
||||||
|
startPartId={(item as ContentDisplayItem).startPartId}
|
||||||
|
messageIndex={props.messageIndex}
|
||||||
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onFork={props.onFork}
|
onFork={props.onFork}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
@@ -448,46 +632,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
<Match when={item.type === "tool"}>
|
<Match when={item.type === "tool"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const toolItem = item as ToolDisplayItem
|
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 (
|
return (
|
||||||
<div class="tool-call-message" data-key={toolItem.key}>
|
<div class="tool-call-message" data-key={toolItem.key}>
|
||||||
<div class="tool-call-header-label">
|
<ToolCallItem
|
||||||
<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}
|
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
|
store={props.store}
|
||||||
|
messageId={toolItem.messageId}
|
||||||
|
partId={toolItem.partId}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,8 +690,9 @@ interface StepCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
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 borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||||
|
|
||||||
const containerClass = () =>
|
const containerClass = () =>
|
||||||
@@ -550,7 +703,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
|
|||||||
class={containerClass()}
|
class={containerClass()}
|
||||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Session compaction"
|
aria-label={t("messageBlock.compaction.ariaLabel")}
|
||||||
>
|
>
|
||||||
<div class="message-compaction-row">
|
<div class="message-compaction-row">
|
||||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||||
@@ -561,6 +714,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StepCard(props: StepCardProps) {
|
function StepCard(props: StepCardProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const timestamp = () => {
|
const timestamp = () => {
|
||||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -607,12 +761,12 @@ function StepCard(props: StepCardProps) {
|
|||||||
|
|
||||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||||
const entries = [
|
const entries = [
|
||||||
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
|
||||||
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal },
|
||||||
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
|
||||||
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
|
||||||
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
|
||||||
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
|
{ label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -647,8 +801,8 @@ function StepCard(props: StepCardProps) {
|
|||||||
<div class="message-step-title-left">
|
<div class="message-step-title-left">
|
||||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
|
||||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
<Show when={modelIdentifier()}>{(value) => <span>{t("messageBlock.step.modelLabel", { model: value() })}</span>}</Show>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -675,6 +829,7 @@ interface ReasoningCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -746,19 +901,29 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-expanded={expanded()}
|
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 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())}>
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
<span class="message-step-meta-inline">
|
<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={agentIdentifier()}>
|
||||||
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
{(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>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
<span class="message-reasoning-meta">
|
<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>
|
||||||
<span class="message-reasoning-time">{timestamp()}</span>
|
<span class="message-reasoning-time">{timestamp()}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -766,7 +931,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<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>
|
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { partHasRenderableText } from "../types/message"
|
|||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
record: MessageRecord
|
record: MessageRecord
|
||||||
@@ -19,6 +20,7 @@ interface MessageItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
||||||
const isUser = () => props.record.role === "user"
|
const isUser = () => props.record.role === "user"
|
||||||
@@ -49,15 +51,15 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
const url = part.url || ""
|
const url = part.url || ""
|
||||||
if (url.startsWith("data:")) {
|
if (url.startsWith("data:")) {
|
||||||
return "attachment"
|
return t("messageItem.attachment.defaultName")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
const segments = parsed.pathname.split("/")
|
const segments = parsed.pathname.split("/")
|
||||||
return segments.pop() || "attachment"
|
return segments.pop() || t("messageItem.attachment.defaultName")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fallback = url.split("/").pop()
|
const fallback = url.split("/").pop()
|
||||||
return fallback && fallback.length > 0 ? fallback : "attachment"
|
return fallback && fallback.length > 0 ? fallback : t("messageItem.attachment.defaultName")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,16 +114,16 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const error = info.error
|
const error = info.error
|
||||||
if (error.name === "ProviderAuthError") {
|
if (error.name === "ProviderAuthError") {
|
||||||
return error.data?.message || "Authentication error"
|
return error.data?.message || t("messageItem.errors.authenticationFallback")
|
||||||
}
|
}
|
||||||
if (error.name === "MessageOutputLengthError") {
|
if (error.name === "MessageOutputLengthError") {
|
||||||
return "Message output length exceeded"
|
return t("messageItem.errors.outputLengthExceeded")
|
||||||
}
|
}
|
||||||
if (error.name === "MessageAbortedError") {
|
if (error.name === "MessageAbortedError") {
|
||||||
return "Request was aborted"
|
return t("messageItem.errors.requestAborted")
|
||||||
}
|
}
|
||||||
if (error.name === "UnknownError") {
|
if (error.name === "UnknownError") {
|
||||||
return error.data?.message || "Unknown error occurred"
|
return error.data?.message || t("messageItem.errors.unknownFallback")
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -135,8 +137,17 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isGenerating = () => {
|
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
|
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 = () => {
|
const handleRevert = () => {
|
||||||
@@ -161,7 +172,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUser() && !hasContent()) {
|
if (!isUser() && !hasContent() && !isGenerating()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +181,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 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)]"
|
: "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 = () => {
|
const agentIdentifier = () => {
|
||||||
if (isUser()) return ""
|
if (isUser()) return ""
|
||||||
@@ -195,10 +206,10 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
const agent = agentIdentifier()
|
const agent = agentIdentifier()
|
||||||
const model = modelIdentifier()
|
const model = modelIdentifier()
|
||||||
if (agent) {
|
if (agent) {
|
||||||
segments.push(`Agent: ${agent}`)
|
segments.push(t("messageItem.agentMeta.agentLabel", { agent }))
|
||||||
}
|
}
|
||||||
if (model) {
|
if (model) {
|
||||||
segments.push(`Model: ${model}`)
|
segments.push(t("messageItem.agentMeta.modelLabel", { model }))
|
||||||
}
|
}
|
||||||
return segments.join(" • ")
|
return segments.join(" • ")
|
||||||
}
|
}
|
||||||
@@ -220,30 +231,30 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleRevert}
|
onClick={handleRevert}
|
||||||
title="Revert to this message"
|
title={t("messageItem.actions.revertTitle")}
|
||||||
aria-label="Revert to this message"
|
aria-label={t("messageItem.actions.revertTitle")}
|
||||||
>
|
>
|
||||||
Revert
|
{t("messageItem.actions.revert")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.onFork}>
|
<Show when={props.onFork}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={() => props.onFork?.(props.record.id)}
|
onClick={() => props.onFork?.(props.record.id)}
|
||||||
title="Fork from this message"
|
title={t("messageItem.actions.forkTitle")}
|
||||||
aria-label="Fork from this message"
|
aria-label={t("messageItem.actions.forkTitle")}
|
||||||
>
|
>
|
||||||
Fork
|
{t("messageItem.actions.fork")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy message"
|
title={t("messageItem.actions.copyTitle")}
|
||||||
aria-label="Copy message"
|
aria-label={t("messageItem.actions.copyTitle")}
|
||||||
>
|
>
|
||||||
<Show when={copied()} fallback="Copy">
|
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||||
Copied!
|
{t("messageItem.actions.copied")}
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,11 +263,11 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy message"
|
title={t("messageItem.actions.copyTitle")}
|
||||||
aria-label="Copy message"
|
aria-label={t("messageItem.actions.copyTitle")}
|
||||||
>
|
>
|
||||||
<Show when={copied()} fallback="Copy">
|
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||||
Copied!
|
{t("messageItem.actions.copied")}
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -269,7 +280,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
|
|
||||||
<Show when={props.isQueued && isUser()}>
|
<Show when={props.isQueued && isUser()}>
|
||||||
<div class="message-queued-badge">QUEUED</div>
|
<div class="message-queued-badge">{t("messageItem.status.queued")}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={errorMessage()}>
|
<Show when={errorMessage()}>
|
||||||
@@ -278,7 +289,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
<Show when={isGenerating()}>
|
<Show when={isGenerating()}>
|
||||||
<div class="message-generating">
|
<div class="message-generating">
|
||||||
<span class="generating-spinner">⏳</span> Generating...
|
<span class="generating-spinner">⏳</span> {t("messageItem.status.generating")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -319,7 +330,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleAttachmentDownload(attachment)}
|
onClick={() => void handleAttachmentDownload(attachment)}
|
||||||
class="attachment-download"
|
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">
|
<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="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||||
@@ -340,12 +351,12 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
<Show when={props.record.status === "sending"}>
|
<Show when={props.record.status === "sending"}>
|
||||||
<div class="message-sending">
|
<div class="message-sending">
|
||||||
<span class="generating-spinner">●</span> Sending...
|
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.record.status === "error"}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
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_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-primary/70"
|
||||||
@@ -17,6 +18,7 @@ interface MessageListHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageListHeader(props: MessageListHeaderProps) {
|
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||||
@@ -29,7 +31,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="session-sidebar-menu-button"
|
class="session-sidebar-menu-button"
|
||||||
onClick={() => props.onSidebarToggle?.()}
|
onClick={() => props.onSidebarToggle?.()}
|
||||||
aria-label="Open session list"
|
aria-label={t("messageListHeader.sidebar.openSessionListAriaLabel")}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -39,11 +41,11 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
<div class="connection-status-text connection-status-info">
|
<div class="connection-status-text connection-status-info">
|
||||||
<div class="connection-status-usage">
|
<div class="connection-status-usage">
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
<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>
|
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
<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>
|
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,8 +53,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
|
|
||||||
<div class="connection-status-text connection-status-shortcut">
|
<div class="connection-status-text connection-status-shortcut">
|
||||||
<div class="connection-status-shortcut-action">
|
<div class="connection-status-shortcut-action">
|
||||||
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
<button
|
||||||
Command Palette
|
type="button"
|
||||||
|
class="connection-status-button"
|
||||||
|
onClick={props.onCommandPalette}
|
||||||
|
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||||
|
>
|
||||||
|
{t("messageListHeader.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
@@ -64,19 +71,19 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
<Show when={props.connectionStatus === "connected"}>
|
<Show when={props.connectionStatus === "connected"}>
|
||||||
<span class="status-indicator connected">
|
<span class="status-indicator connected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connected</span>
|
<span class="status-text">{t("messageListHeader.connection.connected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.connectionStatus === "connecting"}>
|
<Show when={props.connectionStatus === "connecting"}>
|
||||||
<span class="status-indicator connecting">
|
<span class="status-indicator connecting">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connecting...</span>
|
<span class="status-text">{t("messageListHeader.connection.connecting")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||||
<span class="status-indicator disconnected">
|
<span class="status-indicator disconnected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Disconnected</span>
|
<span class="status-text">{t("messageListHeader.connection.disconnected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ interface MessagePartProps {
|
|||||||
const isAssistantMessage = () => props.messageType === "assistant"
|
const isAssistantMessage = () => props.messageType === "assistant"
|
||||||
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
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 plainTextContent = () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
|
|
||||||
@@ -94,7 +101,7 @@ interface MessagePartProps {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={partType() === "text"}>
|
<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()}>
|
<div class={textContainerClass()}>
|
||||||
<Show
|
<Show
|
||||||
when={isAssistantMessage()}
|
when={isAssistantMessage()}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useConfig } from "../stores/preferences"
|
|||||||
import { getSessionInfo } from "../stores/sessions"
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
const SCROLL_SCOPE = "session"
|
const SCROLL_SCOPE = "session"
|
||||||
@@ -31,6 +32,7 @@ export interface MessageSectionProps {
|
|||||||
|
|
||||||
export default function MessageSection(props: MessageSectionProps) {
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
|
const { t } = useI18n()
|
||||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
@@ -107,7 +109,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const record = resolvedStore.getMessage(messageId)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
if (!record) return
|
if (!record) return
|
||||||
seenTimelineMessageIds.add(messageId)
|
seenTimelineMessageIds.add(messageId)
|
||||||
const built = buildTimelineSegments(props.instanceId, record)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
const key = makeTimelineKey(segment)
|
const key = makeTimelineKey(segment)
|
||||||
if (seenTimelineSegmentKeys.has(key)) return
|
if (seenTimelineSegmentKeys.has(key)) return
|
||||||
@@ -121,7 +123,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
function appendTimelineForMessage(messageId: string) {
|
function appendTimelineForMessage(messageId: string) {
|
||||||
const record = untrack(() => store().getMessage(messageId))
|
const record = untrack(() => store().getMessage(messageId))
|
||||||
if (!record) return
|
if (!record) return
|
||||||
const built = buildTimelineSegments(props.instanceId, record)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
if (built.length === 0) return
|
if (built.length === 0) return
|
||||||
const newSegments: TimelineSegment[] = []
|
const newSegments: TimelineSegment[] = []
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
@@ -558,7 +560,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
previousLastTimelineMessageId = lastId
|
previousLastTimelineMessageId = lastId
|
||||||
previousLastTimelinePartCount = partCount
|
previousLastTimelinePartCount = partCount
|
||||||
const built = buildTimelineSegments(props.instanceId, record)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
const newSegments: TimelineSegment[] = []
|
const newSegments: TimelineSegment[] = []
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
const key = makeTimelineKey(segment)
|
const key = makeTimelineKey(segment)
|
||||||
@@ -753,19 +755,19 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-content">
|
<div class="empty-state-content">
|
||||||
<div class="flex flex-col items-center gap-3 mb-6">
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
<img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
|
||||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<h3>Start a conversation</h3>
|
<h3>{t("messageSection.empty.title")}</h3>
|
||||||
<p>Type a message below or open the Command Palette:</p>
|
<p>{t("messageSection.empty.description")}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<span>Command Palette</span>
|
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
||||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||||
</li>
|
</li>
|
||||||
<li>Ask about your codebase</li>
|
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||||
<li>
|
<li>
|
||||||
Attach files with <code>@</code>
|
{t("messageSection.empty.tips.attachFilesPrefix")} <code>@</code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -775,7 +777,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<Show when={props.loading}>
|
<Show when={props.loading}>
|
||||||
<div class="loading-state">
|
<div class="loading-state">
|
||||||
<div class="spinner" />
|
<div class="spinner" />
|
||||||
<p>Loading messages...</p>
|
<p>{t("messageSection.loading.messages")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -803,7 +805,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||||
<div class="message-scroll-button-wrapper">
|
<div class="message-scroll-button-wrapper">
|
||||||
<Show when={showScrollTopButton()}>
|
<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>
|
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -812,7 +814,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="message-scroll-button"
|
class="message-scroll-button"
|
||||||
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
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>
|
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -828,10 +830,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
>
|
>
|
||||||
<div class="message-quote-button-group">
|
<div class="message-quote-button-group">
|
||||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
||||||
Add as quote
|
{t("messageSection.quote.addAsQuote")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||||
Add as code
|
{t("messageSection.quote.addAsCode")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
|
|||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getToolIcon } from "./tool-call/utils"
|
import { getToolIcon } from "./tool-call/utils"
|
||||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||||
|
|
||||||
@@ -29,14 +30,6 @@ interface MessageTimelineProps {
|
|||||||
showToolSegments?: boolean
|
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
|
const MAX_TOOLTIP_LENGTH = 220
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
@@ -90,7 +83,7 @@ function collectReasoningText(part: ClientPart): string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTextFromPart(part: ClientPart): string {
|
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
if (typeof (part as any).text === "string") {
|
if (typeof (part as any).text === "string") {
|
||||||
return (part as any).text as string
|
return (part as any).text as string
|
||||||
@@ -106,26 +99,28 @@ function collectTextFromPart(part: ClientPart): string {
|
|||||||
}
|
}
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const filename = (part as any)?.filename
|
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 ""
|
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 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
|
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
|
||||||
if (title) return title
|
if (title) return title
|
||||||
if (typeof part.tool === "string" && part.tool.length > 0) {
|
if (typeof part.tool === "string" && part.tool.length > 0) {
|
||||||
return part.tool
|
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) {
|
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
|
||||||
return part.tool.trim().slice(0, 4)
|
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 {
|
function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||||
@@ -139,20 +134,34 @@ function formatTextsTooltip(texts: string[], fallback: string): string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatToolTooltip(titles: string[]): string {
|
function formatToolTooltip(
|
||||||
|
titles: string[],
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => string,
|
||||||
|
): string {
|
||||||
if (titles.length === 0) {
|
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 []
|
if (!record) return []
|
||||||
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
||||||
if (!orderedParts || orderedParts.length === 0) {
|
if (!orderedParts || orderedParts.length === 0) {
|
||||||
return []
|
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[] = []
|
const result: TimelineSegment[] = []
|
||||||
let segmentIndex = 0
|
let segmentIndex = 0
|
||||||
let pending: PendingSegment | null = null
|
let pending: PendingSegment | null = null
|
||||||
@@ -164,14 +173,14 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
}
|
}
|
||||||
const isToolSegment = pending.type === "tool"
|
const isToolSegment = pending.type === "tool"
|
||||||
const label = isToolSegment
|
const label = isToolSegment
|
||||||
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
|
? pending.toolTypeLabels[0] || segmentLabel("tool")
|
||||||
: SEGMENT_LABELS[pending.type]
|
: segmentLabel(pending.type)
|
||||||
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||||
const tooltip = isToolSegment
|
const tooltip = isToolSegment
|
||||||
? formatToolTooltip(pending.toolTitles)
|
? formatToolTooltip(pending.toolTitles, t)
|
||||||
: formatTextsTooltip(
|
: formatTextsTooltip(
|
||||||
[...pending.texts, ...pending.reasoningTexts],
|
[...pending.texts, ...pending.reasoningTexts],
|
||||||
pending.type === "user" ? "User message" : "Assistant response",
|
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
@@ -204,8 +213,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
const target = ensureSegment("tool")
|
const target = ensureSegment("tool")
|
||||||
const toolPart = part as ToolCallPart
|
const toolPart = part as ToolCallPart
|
||||||
target.toolTitles.push(getToolTitle(toolPart))
|
target.toolTitles.push(getToolTitle(toolPart, t))
|
||||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
|
||||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||||
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
||||||
target.toolPartIds.push(toolPart.id)
|
target.toolPartIds.push(toolPart.id)
|
||||||
@@ -230,8 +239,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
id: `${record.id}:${segmentIndex}`,
|
id: `${record.id}:${segmentIndex}`,
|
||||||
messageId: record.id,
|
messageId: record.id,
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
label: SEGMENT_LABELS.compaction,
|
label: segmentLabel("compaction"),
|
||||||
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
|
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||||
variant: isAuto ? "auto" : "manual",
|
variant: isAuto ? "auto" : "manual",
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
@@ -242,7 +251,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = collectTextFromPart(part)
|
const text = collectTextFromPart(part, t)
|
||||||
if (text.trim().length === 0) continue
|
if (text.trim().length === 0) continue
|
||||||
const target = ensureSegment(defaultContentType)
|
const target = ensureSegment(defaultContentType)
|
||||||
if (target) {
|
if (target) {
|
||||||
@@ -258,6 +267,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const buttonRefs = new Map<string, HTMLButtonElement>()
|
const buttonRefs = new Map<string, HTMLButtonElement>()
|
||||||
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
||||||
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
||||||
@@ -360,7 +370,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
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}>
|
<For each={props.segments}>
|
||||||
{(segment) => {
|
{(segment) => {
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
@@ -438,4 +448,3 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default MessageTimeline
|
export default MessageTimeline
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Combobox } from "@kobalte/core/combobox"
|
import { Combobox } from "@kobalte/core/combobox"
|
||||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
import { createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import { providers, fetchProviders } from "../stores/sessions"
|
import { providers, fetchProviders } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown, Star } from "lucide-solid"
|
||||||
import type { Model } from "../types/session"
|
import type { Model } from "../types/session"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import Kbd from "./kbd"
|
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -22,10 +23,22 @@ interface FlatModel extends Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ModelSelector(props: ModelSelectorProps) {
|
export default function ModelSelector(props: ModelSelectorProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||||
const [isOpen, setIsOpen] = createSignal(false)
|
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 triggerRef!: HTMLButtonElement
|
||||||
let searchInputRef!: HTMLInputElement
|
let searchInputRef!: HTMLInputElement
|
||||||
|
let listboxRef!: HTMLUListElement
|
||||||
|
let suppressNextClose = false
|
||||||
|
let wasFavoritesOnlyEnabled = false
|
||||||
|
let wasCurrentModelFavorite = false
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (instanceProviders().length === 0) {
|
if (instanceProviders().length === 0) {
|
||||||
@@ -44,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(() =>
|
const currentModelValue = createMemo(() =>
|
||||||
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
|
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) => {
|
const handleChange = async (value: FlatModel | null) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const customFilter = (option: FlatModel, inputValue: string) => {
|
const customFilter = (option: FlatModel, rawInput: string) => {
|
||||||
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
|
if (!searchDirty()) return true
|
||||||
|
return option.searchText.toLowerCase().includes(rawInput.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isOpen()) {
|
if (isOpen()) {
|
||||||
|
setManualAll(false)
|
||||||
|
setExplicitFavorites(false)
|
||||||
|
setAutoFavoritesEligibleAtOpen(hasFavorites() && currentModelIsFavorite())
|
||||||
|
setSearchDirty(false)
|
||||||
|
setInitialQuery("")
|
||||||
|
setInputValue("")
|
||||||
|
setInitialQueryReady(false)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const seeded = searchInputRef?.value ?? ""
|
||||||
|
setInitialQuery(seeded)
|
||||||
|
setInputValue(seeded)
|
||||||
|
setInitialQueryReady(true)
|
||||||
searchInputRef?.focus()
|
searchInputRef?.focus()
|
||||||
|
searchInputRef?.select()
|
||||||
}, 100)
|
}, 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 (
|
return (
|
||||||
<div class="sidebar-selector">
|
<div class="sidebar-selector">
|
||||||
<Combobox<FlatModel>
|
<Combobox<FlatModel>
|
||||||
|
open={isOpen()}
|
||||||
value={currentModelValue()}
|
value={currentModelValue()}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={(next) => {
|
||||||
options={allModels()}
|
if (!next && suppressNextClose) return
|
||||||
|
setIsOpen(next)
|
||||||
|
}}
|
||||||
|
options={visibleOptions()}
|
||||||
optionValue="key"
|
optionValue="key"
|
||||||
optionTextValue="searchText"
|
optionTextValue="searchText"
|
||||||
optionLabel="name"
|
optionLabel="name"
|
||||||
placeholder="Search models..."
|
placeholder={t("modelSelector.placeholder.search")}
|
||||||
defaultFilter={customFilter}
|
defaultFilter={customFilter}
|
||||||
allowsEmptyCollection
|
allowsEmptyCollection
|
||||||
itemComponent={(itemProps) => (
|
itemComponent={(itemProps) => {
|
||||||
<Combobox.Item
|
const isFavorite = () => favoriteKeySet().has(itemProps.item.rawValue.key)
|
||||||
item={itemProps.item}
|
return (
|
||||||
class="selector-option"
|
<Combobox.Item
|
||||||
>
|
item={itemProps.item}
|
||||||
<div class="selector-option-content">
|
class="selector-option"
|
||||||
<Combobox.ItemLabel class="selector-option-label">
|
>
|
||||||
{itemProps.item.rawValue.name}
|
<>
|
||||||
</Combobox.ItemLabel>
|
<div class="selector-option-content">
|
||||||
<Combobox.ItemDescription class="selector-option-description">
|
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Combobox.ItemLabel>
|
||||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/
|
<Combobox.ItemDescription class="selector-option-description">
|
||||||
{itemProps.item.rawValue.id}
|
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
|
||||||
</Combobox.ItemDescription>
|
</Combobox.ItemDescription>
|
||||||
</div>
|
</div>
|
||||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</Combobox.ItemIndicator>
|
</Combobox.ItemIndicator>
|
||||||
</Combobox.Item>
|
<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.Control class="relative w-full" data-model-selector-control>
|
||||||
<Combobox.Input class="sr-only" data-model-selector />
|
<Combobox.Input class="sr-only" data-model-selector />
|
||||||
@@ -108,17 +292,14 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
>
|
>
|
||||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
<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>
|
</span>
|
||||||
{currentModelValue() && (
|
{currentModelValue() && (
|
||||||
<span class="selector-trigger-secondary">
|
<span class="selector-trigger-secondary">
|
||||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
|
||||||
<Kbd shortcut="cmd+shift+m" />
|
|
||||||
</span>
|
|
||||||
<Combobox.Icon class="selector-trigger-icon">
|
<Combobox.Icon class="selector-trigger-icon">
|
||||||
<ChevronDown class="w-3 h-3" />
|
<ChevronDown class="w-3 h-3" />
|
||||||
</Combobox.Icon>
|
</Combobox.Icon>
|
||||||
@@ -128,13 +309,53 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
<Combobox.Portal>
|
<Combobox.Portal>
|
||||||
<Combobox.Content class="selector-popover">
|
<Combobox.Content class="selector-popover">
|
||||||
<div class="selector-search-container">
|
<div class="selector-search-container">
|
||||||
<Combobox.Input
|
<div class="selector-input-group">
|
||||||
ref={searchInputRef}
|
<Combobox.Input
|
||||||
class="selector-search-input"
|
ref={searchInputRef}
|
||||||
placeholder="Search models..."
|
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>
|
</div>
|
||||||
<Combobox.Listbox class="selector-listbox" />
|
|
||||||
</Combobox.Content>
|
</Combobox.Content>
|
||||||
</Combobox.Portal>
|
</Combobox.Portal>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
|
|||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ interface OpenCodeBinarySelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
@@ -103,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validatingPaths().has(path)) {
|
if (validatingPaths().has(path)) {
|
||||||
return { valid: false, error: "Already validating" }
|
return { valid: false, error: t("opencodeBinarySelector.validation.alreadyValidating") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -139,7 +141,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
if (nativeDialogsAvailable) {
|
if (nativeDialogsAvailable) {
|
||||||
const selected = await openNativeFileDialog({
|
const selected = await openNativeFileDialog({
|
||||||
title: "Select OpenCode Binary",
|
title: t("opencodeBinarySelector.dialog.title"),
|
||||||
})
|
})
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setCustomPath(selected)
|
setCustomPath(selected)
|
||||||
@@ -160,7 +162,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
setCustomPath("")
|
setCustomPath("")
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
} else {
|
} 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 hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDisplayName(path: string): string {
|
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(/[/\\]/)
|
const parts = path.split(/[/\\]/)
|
||||||
return parts[parts.length - 1] ?? path
|
return parts[parts.length - 1] ?? path
|
||||||
}
|
}
|
||||||
@@ -221,13 +223,13 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header flex items-center justify-between gap-3">
|
<div class="panel-header flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="panel-title">OpenCode Binary</h3>
|
<h3 class="panel-title">{t("opencodeBinarySelector.title")}</h3>
|
||||||
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
<p class="panel-subtitle">{t("opencodeBinarySelector.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Show when={validating()}>
|
<Show when={validating()}>
|
||||||
<div class="selector-loading text-xs">
|
<div class="selector-loading text-xs">
|
||||||
<Loader2 class="selector-loading-spinner" />
|
<Loader2 class="selector-loading-spinner" />
|
||||||
<span>Checking versions…</span>
|
<span>{t("opencodeBinarySelector.status.checkingVersions")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +247,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
placeholder="Enter path to opencode binary…"
|
placeholder={t("opencodeBinarySelector.customPath.placeholder")}
|
||||||
class="selector-input"
|
class="selector-input"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -255,7 +257,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
class="selector-button selector-button-primary"
|
class="selector-button selector-button-primary"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
Add
|
{t("opencodeBinarySelector.actions.add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4" />
|
<FolderOpen class="w-4 h-4" />
|
||||||
Browse for Binary…
|
{t("opencodeBinarySelector.actions.browse")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={validationError()}>
|
<Show when={validationError()}>
|
||||||
@@ -308,16 +310,16 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
||||||
<Show when={versionLabel()}>
|
<Show when={versionLabel()}>
|
||||||
<span class="selector-badge-version">v{versionLabel()}</span>
|
<span class="selector-badge-version">{t("opencodeBinarySelector.versionLabel", { version: versionLabel() })}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isPathValidating(binary.path)}>
|
<Show when={isPathValidating(binary.path)}>
|
||||||
<span class="selector-badge-time">Checking…</span>
|
<span class="selector-badge-time">{t("opencodeBinarySelector.status.checking")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isDefault && binary.lastUsed}>
|
<Show when={!isDefault && binary.lastUsed}>
|
||||||
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isDefault}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,7 +330,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
class="p-2 text-muted hover:text-primary"
|
class="p-2 text-muted hover:text-primary"
|
||||||
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
title="Remove binary"
|
title={t("opencodeBinarySelector.actions.removeTitle")}
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -343,8 +345,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
<FileSystemBrowserDialog
|
<FileSystemBrowserDialog
|
||||||
open={isBinaryBrowserOpen()}
|
open={isBinaryBrowserOpen()}
|
||||||
mode="files"
|
mode="files"
|
||||||
title="Select OpenCode Binary"
|
title={t("opencodeBinarySelector.dialog.title")}
|
||||||
description="Browse files exposed by the CLI server."
|
description={t("opencodeBinarySelector.dialog.description")}
|
||||||
onClose={() => setIsBinaryBrowserOpen(false)}
|
onClose={() => setIsBinaryBrowserOpen(false)}
|
||||||
onSelect={handleBinaryBrowserSelect}
|
onSelect={handleBinaryBrowserSelect}
|
||||||
/>
|
/>
|
||||||
@@ -353,4 +355,3 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default OpenCodeBinarySelector
|
export default OpenCodeBinarySelector
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Comp
|
|||||||
import type { PermissionRequestLike } from "../types/permission"
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||||
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import {
|
import {
|
||||||
activeInterruption,
|
activeInterruption,
|
||||||
getPermissionQueue,
|
getPermissionQueue,
|
||||||
@@ -130,6 +131,7 @@ function resolveToolCallFromQuestion(instanceId: string, request: QuestionReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
|
||||||
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
|
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
|
||||||
@@ -165,7 +167,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
const sessionId = getPermissionSessionId(permission) || ""
|
const sessionId = getPermissionSessionId(permission) || ""
|
||||||
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
|
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setPermissionBusy(permissionId, false)
|
setPermissionBusy(permissionId, false)
|
||||||
}
|
}
|
||||||
@@ -257,19 +262,24 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
<div class="permission-center-modal-header">
|
<div class="permission-center-modal-header">
|
||||||
<div class="permission-center-modal-title-row">
|
<div class="permission-center-modal-title-row">
|
||||||
<h2 id="permission-center-title" class="permission-center-modal-title">
|
<h2 id="permission-center-title" class="permission-center-modal-title">
|
||||||
Requests
|
{t("permissionApproval.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<Show when={orderedQueue().length > 0}>
|
<Show when={orderedQueue().length > 0}>
|
||||||
<span class="permission-center-modal-count">{orderedQueue().length}</span>
|
<span class="permission-center-modal-count">{orderedQueue().length}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="permission-center-modal-body">
|
<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">
|
<div class="permission-center-list" role="list">
|
||||||
<For each={orderedQueue()}>
|
<For each={orderedQueue()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
@@ -285,14 +295,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
|
|
||||||
const showFallback = () => !resolved()
|
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 = () => {
|
const primaryTitle = () => {
|
||||||
if (item.kind === "permission") {
|
if (item.kind === "permission") {
|
||||||
return getPermissionDisplayTitle(item.payload)
|
return getPermissionDisplayTitle(item.payload)
|
||||||
}
|
}
|
||||||
const first = item.payload.questions?.[0]?.question
|
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 = () => {
|
const secondaryTitle = () => {
|
||||||
@@ -300,7 +313,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
return getPermissionKind(item.payload)
|
return getPermissionKind(item.payload)
|
||||||
}
|
}
|
||||||
const count = item.payload.questions?.length ?? 0
|
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 (
|
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-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
|
||||||
<span class="permission-center-item-kind">{secondaryTitle()}</span>
|
<span class="permission-center-item-kind">{secondaryTitle()}</span>
|
||||||
<Show when={isActive()}>
|
<Show when={isActive()}>
|
||||||
<span class="permission-center-item-chip">Active</span>
|
<span class="permission-center-item-chip">{t("permissionApproval.status.active")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -326,7 +341,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
handleGoToSession(sessionId())
|
handleGoToSession(sessionId())
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Go to Session
|
{t("permissionApproval.actions.goToSession")}
|
||||||
</button>
|
</button>
|
||||||
<Show when={showFallback()}>
|
<Show when={showFallback()}>
|
||||||
<button
|
<button
|
||||||
@@ -338,7 +353,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
handleLoadSession(sessionId())
|
handleLoadSession(sessionId())
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
|
{loadingSession() === sessionId()
|
||||||
|
? t("permissionApproval.actions.loadingSession")
|
||||||
|
: t("permissionApproval.actions.loadSession")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,7 +377,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
disabled={permissionSubmitting().has(item.id)}
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
|
||||||
>
|
>
|
||||||
Allow Once
|
{t("permissionApproval.actions.allowOnce")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -368,7 +385,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
disabled={permissionSubmitting().has(item.id)}
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
|
||||||
>
|
>
|
||||||
Always Allow
|
{t("permissionApproval.actions.alwaysAllow")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -376,7 +393,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
disabled={permissionSubmitting().has(item.id)}
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
|
||||||
>
|
>
|
||||||
Deny
|
{t("permissionApproval.actions.deny")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,7 +402,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.kind !== "permission"}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Show, createMemo, type Component } from "solid-js"
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
import { ShieldAlert } from "lucide-solid"
|
import { ShieldAlert } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
|
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
|
||||||
|
|
||||||
interface PermissionNotificationBannerProps {
|
interface PermissionNotificationBannerProps {
|
||||||
@@ -8,17 +9,38 @@ interface PermissionNotificationBannerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
|
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
|
||||||
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
|
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
|
||||||
const queueLength = createMemo(() => permissionCount() + questionCount())
|
const queueLength = createMemo(() => permissionCount() + questionCount())
|
||||||
const hasRequests = createMemo(() => queueLength() > 0)
|
const hasRequests = createMemo(() => queueLength() > 0)
|
||||||
const label = createMemo(() => {
|
const label = createMemo(() => {
|
||||||
const total = queueLength()
|
const total = queueLength()
|
||||||
|
|
||||||
|
const pendingLabel = total === 1
|
||||||
|
? t("permissionBanner.pendingRequests.one", { count: total })
|
||||||
|
: t("permissionBanner.pendingRequests.other", { count: total })
|
||||||
|
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
|
|
||||||
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
|
if (permissionCount() > 0) {
|
||||||
const detail = parts.length ? ` (${parts.join(", ")})` : ""
|
parts.push(
|
||||||
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
|
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 (
|
return (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getActiveInstance } from "../stores/instances"
|
|||||||
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
|
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
|
||||||
import { getCommands } from "../stores/commands"
|
import { getCommands } from "../stores/commands"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ interface PromptInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [prompt, setPromptInternal] = createSignal("")
|
const [prompt, setPromptInternal] = createSignal("")
|
||||||
const [history, setHistory] = createSignal<string[]>([])
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
const HISTORY_LIMIT = 100
|
const HISTORY_LIMIT = 100
|
||||||
@@ -53,9 +55,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
|
|
||||||
const getPlaceholder = () => {
|
const getPlaceholder = () => {
|
||||||
if (mode() === "shell") {
|
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) {
|
} catch (error) {
|
||||||
log.error("Failed to send message:", error)
|
log.error("Failed to send message:", error)
|
||||||
showAlertDialog("Failed to send message", {
|
showAlertDialog(t("promptInput.send.errorFallback"), {
|
||||||
title: "Send failed",
|
title: t("promptInput.send.errorTitle"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
@@ -1048,8 +1050,11 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
return hasText || attachments().length > 0
|
return hasText || attachments().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
|
const shellHint = () =>
|
||||||
const commandHint = () => ({ key: "/", text: "Commands" })
|
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
|
const shouldShowOverlay = () => prompt().length === 0
|
||||||
|
|
||||||
@@ -1115,7 +1120,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
class="prompt-history-button"
|
class="prompt-history-button"
|
||||||
onClick={() => selectPreviousHistory(true)}
|
onClick={() => selectPreviousHistory(true)}
|
||||||
disabled={!canHistoryGoPrevious()}
|
disabled={!canHistoryGoPrevious()}
|
||||||
aria-label="Previous prompt"
|
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||||
>
|
>
|
||||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1124,7 +1129,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
class="prompt-history-button"
|
class="prompt-history-button"
|
||||||
onClick={() => selectNextHistory(true)}
|
onClick={() => selectNextHistory(true)}
|
||||||
disabled={!canHistoryGoNext()}
|
disabled={!canHistoryGoNext()}
|
||||||
aria-label="Next prompt"
|
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||||
>
|
>
|
||||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1137,10 +1142,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<span class="prompt-overlay-text">
|
<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>
|
</span>
|
||||||
<Show when={attachments().length > 0}>
|
<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>
|
</Show>
|
||||||
<span class="prompt-overlay-text">
|
<span class="prompt-overlay-text">
|
||||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||||
@@ -1151,17 +1156,17 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={mode() === "shell"}>
|
<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>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<span class="prompt-overlay-text prompt-overlay-warning">
|
<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>
|
</span>
|
||||||
<Show when={mode() === "shell"}>
|
<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>
|
||||||
</>
|
</>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1177,8 +1182,8 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
class="stop-button"
|
class="stop-button"
|
||||||
onClick={handleAbort}
|
onClick={handleAbort}
|
||||||
disabled={!canStop()}
|
disabled={!canStop()}
|
||||||
aria-label="Stop session"
|
aria-label={t("promptInput.stopSession.ariaLabel")}
|
||||||
title="Stop session"
|
title={t("promptInput.stopSession.title")}
|
||||||
>
|
>
|
||||||
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<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" />
|
<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" : ""}`}
|
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!canSend()}
|
disabled={!canSend()}
|
||||||
aria-label="Send message"
|
aria-label={t("promptInput.send.ariaLabel")}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={mode() === "shell"}
|
when={mode() === "shell"}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { restartCli } from "../lib/native/cli"
|
|||||||
import { preferences, setListeningMode } from "../stores/preferences"
|
import { preferences, setListeningMode } from "../stores/preferences"
|
||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ interface RemoteAccessOverlayProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
@@ -85,11 +87,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", {
|
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||||
title: allow ? "Open to other devices" : "Limit to this device",
|
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: "Restart now",
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
@@ -100,7 +102,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
setListeningMode(targetMode)
|
setListeningMode(targetMode)
|
||||||
const restarted = await restartCli()
|
const restarted = await restartCli()
|
||||||
if (!restarted) {
|
if (!restarted) {
|
||||||
setError("Unable to restart automatically. Please restart the app to apply the change.")
|
setError(t("remoteAccess.restart.errorManual"))
|
||||||
} else {
|
} else {
|
||||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||||
}
|
}
|
||||||
@@ -123,12 +125,12 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const confirm = passwordConfirm()
|
const confirm = passwordConfirm()
|
||||||
|
|
||||||
if (next.trim().length < 8) {
|
if (next.trim().length < 8) {
|
||||||
setPasswordError("Password must be at least 8 characters.")
|
setPasswordError(t("remoteAccess.password.error.tooShort"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next !== confirm) {
|
if (next !== confirm) {
|
||||||
setPasswordError("Passwords do not match.")
|
setPasswordError(t("remoteAccess.password.error.mismatch"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,11 +164,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
|
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
|
||||||
<header class="remote-header">
|
<header class="remote-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-eyebrow">Remote handover</p>
|
<p class="remote-eyebrow">{t("remoteAccess.eyebrow")}</p>
|
||||||
<h2 class="remote-title">Connect to CodeNomad remotely</h2>
|
<h2 class="remote-title">{t("remoteAccess.title")}</h2>
|
||||||
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p>
|
<p class="remote-subtitle">{t("remoteAccess.subtitle")}</p>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -177,13 +179,13 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
<div class="remote-section-title">
|
<div class="remote-section-title">
|
||||||
<Shield class="remote-icon" />
|
<Shield class="remote-icon" />
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-label">Listening mode</p>
|
<p class="remote-label">{t("remoteAccess.sections.listeningMode.label")}</p>
|
||||||
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p>
|
<p class="remote-help">{t("remoteAccess.sections.listeningMode.help")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
|
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
|
||||||
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
|
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
|
||||||
<span class="remote-refresh-label">Refresh</span>
|
<span class="remote-refresh-label">{t("remoteAccess.refresh")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,19 +198,18 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
>
|
>
|
||||||
<Switch.Input />
|
<Switch.Input />
|
||||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
<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.Thumb class="remote-toggle-thumb" />
|
||||||
</Switch.Control>
|
</Switch.Control>
|
||||||
<div class="remote-toggle-copy">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Switch>
|
</Switch>
|
||||||
<p class="remote-toggle-note">
|
<p class="remote-toggle-note">
|
||||||
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the
|
{t("remoteAccess.toggle.note")}
|
||||||
server restarts.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -217,22 +218,24 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
<div class="remote-section-title">
|
<div class="remote-section-title">
|
||||||
<Shield class="remote-icon" />
|
<Shield class="remote-icon" />
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-label">Server password</p>
|
<p class="remote-label">{t("remoteAccess.sections.serverPassword.label")}</p>
|
||||||
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
|
<p class="remote-help">{t("remoteAccess.sections.serverPassword.help")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={authStatus() && authStatus()!.authenticated}
|
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">
|
<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">
|
<p class="remote-help">
|
||||||
{authStatus()!.passwordUserProvided
|
{authStatus()!.passwordUserProvided
|
||||||
? "A password is set for remote access."
|
? t("remoteAccess.password.status.set")
|
||||||
: "No memorable password is set yet. Set one to allow remote handover logins."}
|
: t("remoteAccess.password.status.unset")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||||
@@ -245,26 +248,26 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{passwordFormOpen()
|
{passwordFormOpen()
|
||||||
? "Cancel"
|
? t("remoteAccess.password.actions.cancel")
|
||||||
: authStatus()!.passwordUserProvided
|
: authStatus()!.passwordUserProvided
|
||||||
? "Change password"
|
? t("remoteAccess.password.actions.change")
|
||||||
: "Set password"}
|
: t("remoteAccess.password.actions.set")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={passwordFormOpen()}>
|
<Show when={passwordFormOpen()}>
|
||||||
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
|
<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
|
<input
|
||||||
class="selector-input w-full"
|
class="selector-input w-full"
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordValue()}
|
value={passwordValue()}
|
||||||
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||||
placeholder="At least 8 characters"
|
placeholder={t("remoteAccess.password.form.placeholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
|
<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
|
<input
|
||||||
class="selector-input w-full"
|
class="selector-input w-full"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -284,7 +287,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
disabled={savingPassword()}
|
disabled={savingPassword()}
|
||||||
onClick={() => void handleSubmitPassword()}
|
onClick={() => void handleSubmitPassword()}
|
||||||
>
|
>
|
||||||
{savingPassword() ? "Saving…" : "Save password"}
|
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -298,33 +301,39 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
<div class="remote-section-title">
|
<div class="remote-section-title">
|
||||||
<Wifi class="remote-icon" />
|
<Wifi class="remote-icon" />
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-label">Reachable addresses</p>
|
<p class="remote-label">{t("remoteAccess.sections.addresses.label")}</p>
|
||||||
<p class="remote-help">Launch or scan from another machine to hand over control.</p>
|
<p class="remote-help">{t("remoteAccess.sections.addresses.help")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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={!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">
|
<div class="remote-address-list">
|
||||||
<For each={displayAddresses()}>
|
<For each={displayAddresses()}>
|
||||||
{(address) => {
|
{(address) => {
|
||||||
const expandedState = () => expandedUrl() === address.url
|
const expandedState = () => expandedUrl() === address.url
|
||||||
const qr = () => qrCodes()[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 (
|
return (
|
||||||
<div class="remote-address">
|
<div class="remote-address">
|
||||||
<div class="remote-address-main">
|
<div class="remote-address-main">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-address-url">{address.url}</p>
|
<p class="remote-address-url">{address.url}</p>
|
||||||
<p class="remote-address-meta">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="remote-actions">
|
<div class="remote-actions">
|
||||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
||||||
<ExternalLink class="remote-icon" />
|
<ExternalLink class="remote-icon" />
|
||||||
Open
|
{t("remoteAccess.address.open")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="remote-pill"
|
class="remote-pill"
|
||||||
@@ -333,14 +342,20 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
aria-expanded={expandedState()}
|
aria-expanded={expandedState()}
|
||||||
>
|
>
|
||||||
<Link2 class="remote-icon" />
|
<Link2 class="remote-icon" />
|
||||||
{expandedState() ? "Hide QR" : "Show QR"}
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={expandedState()}>
|
<Show when={expandedState()}>
|
||||||
<div class="remote-qr">
|
<div class="remote-qr">
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import KeyboardHint from "./keyboard-hint"
|
|||||||
import SessionRenameDialog from "./session-rename-dialog"
|
import SessionRenameDialog from "./session-rename-dialog"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { showToastNotification } from "../lib/notifications"
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import {
|
import {
|
||||||
deleteSession,
|
deleteSession,
|
||||||
ensureSessionParentExpanded,
|
ensureSessionParentExpanded,
|
||||||
@@ -37,17 +38,11 @@ interface SessionListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSessionStatus(status: SessionStatus): string {
|
function formatSessionStatus(status: SessionStatus): string {
|
||||||
switch (status) {
|
return status
|
||||||
case "working":
|
|
||||||
return "Working"
|
|
||||||
case "compacting":
|
|
||||||
return "Compacting"
|
|
||||||
default:
|
|
||||||
return "Idle"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionList: Component<SessionListProps> = (props) => {
|
const SessionList: Component<SessionListProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||||
|
|
||||||
@@ -73,13 +68,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
try {
|
try {
|
||||||
const success = await copyToClipboard(sessionId)
|
const success = await copyToClipboard(sessionId)
|
||||||
if (success) {
|
if (success) {
|
||||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
showToastNotification({ message: t("sessionList.copyId.success"), variant: "success" })
|
||||||
} else {
|
} else {
|
||||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`Failed to copy session ID ${sessionId}:`, 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" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +122,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`Failed to delete session ${sessionId}:`, 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,7 +147,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
setRenameTarget(null)
|
setRenameTarget(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`Failed to rename session ${target.id}:`, 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 {
|
} finally {
|
||||||
setIsRenaming(false)
|
setIsRenaming(false)
|
||||||
}
|
}
|
||||||
@@ -172,14 +167,28 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
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 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 needsPermission = () => Boolean(session()?.pendingPermission)
|
||||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||||
const needsInput = () => needsPermission() || needsQuestion()
|
const needsInput = () => needsPermission() || needsQuestion()
|
||||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
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 (
|
return (
|
||||||
<div class="session-list-item group">
|
<div class="session-list-item group">
|
||||||
@@ -219,8 +228,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"}
|
aria-label={rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")}
|
||||||
title={rowProps.expanded ? "Collapse" : "Expand"}
|
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"}`} />
|
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||||
</span>
|
</span>
|
||||||
@@ -240,8 +249,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
onClick={(event) => copySessionId(event, rowProps.sessionId)}
|
onClick={(event) => copySessionId(event, rowProps.sessionId)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Copy session ID"
|
aria-label={t("sessionList.actions.copyId.ariaLabel")}
|
||||||
title="Copy session ID"
|
title={t("sessionList.actions.copyId.title")}
|
||||||
>
|
>
|
||||||
<Copy class="w-3 h-3" />
|
<Copy class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
@@ -253,8 +262,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Rename session"
|
aria-label={t("sessionList.actions.rename.ariaLabel")}
|
||||||
title="Rename session"
|
title={t("sessionList.actions.rename.title")}
|
||||||
>
|
>
|
||||||
<Pencil class="w-3 h-3" />
|
<Pencil class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
@@ -263,8 +272,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Delete session"
|
aria-label={t("sessionList.actions.delete.ariaLabel")}
|
||||||
title="Delete session"
|
title={t("sessionList.actions.delete.title")}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={!isSessionDeleting(rowProps.sessionId)}
|
when={!isSessionDeleting(rowProps.sessionId)}
|
||||||
@@ -360,7 +369,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-list-header p-3 border-b border-base">
|
<div class="session-list-header p-3 border-b border-base">
|
||||||
{props.headerContent ?? (
|
{props.headerContent ?? (
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
||||||
/>
|
/>
|
||||||
@@ -420,4 +429,3 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SessionList
|
export default SessionList
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getParentSessions, createSession, setActiveParentSession } from "../sto
|
|||||||
import { instances, stopInstance } from "../stores/instances"
|
import { instances, stopInstance } from "../stores/instances"
|
||||||
import { agents } from "../stores/sessions"
|
import { agents } from "../stores/sessions"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface SessionPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SessionPicker: Component<SessionPickerProps> = (props) => {
|
const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
||||||
const [isCreating, setIsCreating] = createSignal(false)
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
|
|
||||||
@@ -40,10 +42,10 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSessionSelect(sessionId: string) {
|
async function handleSessionSelect(sessionId: string) {
|
||||||
@@ -74,19 +76,19 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<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.Content class="modal-surface w-full max-w-lg p-6">
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
|
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
|
||||||
OpenCode • {instance()?.folder.split("/").pop()}
|
{t("sessionPicker.title", { folder: instance()?.folder.split("/").pop() })}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<Show
|
<Show
|
||||||
when={parentSessions().length > 0}
|
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>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-secondary mb-2">
|
<h3 class="text-sm font-medium text-secondary mb-2">
|
||||||
Resume a session ({parentSessions().length}):
|
{t("sessionPicker.resume.title", { count: parentSessions().length })}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-1 max-h-[400px] overflow-y-auto">
|
<div class="space-y-1 max-h-[400px] overflow-y-auto">
|
||||||
<For each={parentSessions()}>
|
<For each={parentSessions()}>
|
||||||
@@ -98,7 +100,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="selector-option-content w-full">
|
<div class="selector-option-content w-full">
|
||||||
<span class="selector-option-label truncate">
|
<span class="selector-option-label truncate">
|
||||||
{session.title || "Untitled"}
|
{session.title || t("sessionPicker.session.untitled")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="selector-badge-time flex-shrink-0">
|
<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 class="w-full border-t border-base" />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex justify-center text-sm">
|
<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>
|
</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">
|
<div class="space-y-3">
|
||||||
<Show
|
<Show
|
||||||
when={agentList().length > 0}
|
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
|
<select
|
||||||
class="selector-input w-full"
|
class="selector-input w-full"
|
||||||
@@ -161,9 +163,13 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isCreating()}
|
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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<kbd class="kbd ml-2">
|
<kbd class="kbd ml-2">
|
||||||
@@ -180,7 +186,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("sessionPicker.actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface SessionRenameDialogProps {
|
interface SessionRenameDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -11,6 +12,7 @@ interface SessionRenameDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [title, setTitle] = createSignal("")
|
const [title, setTitle] = createSignal("")
|
||||||
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
||||||
let inputRef: HTMLInputElement | undefined
|
let inputRef: HTMLInputElement | undefined
|
||||||
@@ -40,9 +42,9 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
|
|
||||||
const description = () => {
|
const description = () => {
|
||||||
if (props.sessionLabel && props.sessionLabel.trim()) {
|
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 (
|
return (
|
||||||
@@ -58,7 +60,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<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.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">
|
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||||
{description()}
|
{description()}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
@@ -66,7 +68,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-medium text-secondary" for={inputId}>
|
<label class="text-sm font-medium text-secondary" for={inputId}>
|
||||||
Session name
|
{t("sessionRenameDialog.input.label")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
@@ -76,7 +78,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={title()}
|
value={title()}
|
||||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
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"
|
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +94,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
disabled={isSubmitting()}
|
disabled={isSubmitting()}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("sessionRenameDialog.actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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>
|
</svg>
|
||||||
<span>Renaming…</span>
|
<span>{t("sessionRenameDialog.actions.renaming")}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Rename
|
{t("sessionRenameDialog.actions.rename")}
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createMemo, type Component } from "solid-js"
|
import { createMemo, type Component } from "solid-js"
|
||||||
import { getSessionInfo } from "../../stores/sessions"
|
import { getSessionInfo } from "../../stores/sessions"
|
||||||
import { formatTokenTotal } from "../../lib/formatters"
|
import { formatTokenTotal } from "../../lib/formatters"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
interface ContextUsagePanelProps {
|
interface ContextUsagePanelProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -12,6 +13,7 @@ const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
|||||||
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||||
|
|
||||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const info = createMemo(
|
const info = createMemo(
|
||||||
() =>
|
() =>
|
||||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
@@ -39,7 +41,7 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
const formatTokenValue = (value: number | null | undefined) => {
|
const formatTokenValue = (value: number | null | undefined) => {
|
||||||
if (value === null || value === undefined) return "--"
|
if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
|
||||||
return formatTokenTotal(value)
|
return formatTokenTotal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,29 +50,29 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
<div class={headingClass}>Tokens</div>
|
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
|
||||||
<div class={chipClass}>
|
<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>
|
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={chipClass}>
|
<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>
|
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={chipClass}>
|
<div class={chipClass}>
|
||||||
<span class={chipLabelClass}>Cost</span>
|
<span class={chipLabelClass}>{t("contextUsagePanel.labels.cost")}</span>
|
||||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
<div class={headingClass}>Context</div>
|
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
|
||||||
<div class={chipClass}>
|
<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>
|
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={chipClass}>
|
<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>
|
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import PromptInput from "../prompt-input"
|
|||||||
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
import { instances } from "../../stores/instances"
|
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 { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { requestData } from "../../lib/opencode-api"
|
import { requestData } from "../../lib/opencode-api"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ interface SessionViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const session = () => props.activeSessions.get(props.sessionId)
|
const session = () => props.activeSessions.get(props.sessionId)
|
||||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
@@ -152,8 +154,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to abort session", error)
|
log.error("Failed to abort session", error)
|
||||||
showAlertDialog("Failed to stop session", {
|
showAlertDialog(t("sessionView.alerts.abortFailed.message"), {
|
||||||
title: "Stop failed",
|
title: t("sessionView.alerts.abortFailed.title"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
@@ -201,8 +203,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to revert message", error)
|
log.error("Failed to revert message", error)
|
||||||
showAlertDialog("Failed to revert to message", {
|
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
|
||||||
title: "Revert failed",
|
title: t("sessionView.alerts.revertFailed.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -215,10 +217,15 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const restoredText = getUserMessageText(messageId)
|
const restoredText = getUserMessageText(messageId)
|
||||||
|
const parentTitle = (session()?.title ?? "").trim() || t("sessionList.session.untitled")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
|
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
|
const parentToActivate = forkedSession.parentId ?? forkedSession.id
|
||||||
setActiveParentSession(props.instanceId, parentToActivate)
|
setActiveParentSession(props.instanceId, parentToActivate)
|
||||||
if (forkedSession.parentId) {
|
if (forkedSession.parentId) {
|
||||||
@@ -237,8 +244,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to fork session", error)
|
log.error("Failed to fork session", error)
|
||||||
showAlertDialog("Failed to fork session", {
|
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
|
||||||
title: "Fork failed",
|
title: t("sessionView.alerts.forkFailed.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -250,7 +257,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
when={session()}
|
when={session()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -296,8 +303,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="attachment-expand"
|
class="attachment-expand"
|
||||||
onClick={() => handleExpandTextAttachment(attachment)}
|
onClick={() => handleExpandTextAttachment(attachment)}
|
||||||
aria-label="Expand pasted text"
|
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||||
title="Insert pasted text"
|
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||||
>
|
>
|
||||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -306,7 +313,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="attachment-remove"
|
class="attachment-remove"
|
||||||
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||||
aria-label="Remove attachment"
|
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
|
|||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
|
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
|
||||||
import Kbd from "./kbd"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ type ThinkingOption = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -37,7 +38,10 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
|||||||
|
|
||||||
const options = createMemo<ThinkingOption[]>(() => {
|
const options = createMemo<ThinkingOption[]>(() => {
|
||||||
const keys = variantKeys()
|
const keys = variantKeys()
|
||||||
return [{ key: "__default__", label: "Default", value: undefined }, ...keys.map((k) => ({ key: k, label: k, value: k }))]
|
return [
|
||||||
|
{ key: "__default__", label: t("thinkingSelector.variant.default"), value: undefined },
|
||||||
|
...keys.map((k) => ({ key: k, label: k, value: k })),
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentValue = createMemo(() => {
|
const currentValue = createMemo(() => {
|
||||||
@@ -56,7 +60,8 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
|||||||
|
|
||||||
const triggerPrimary = createMemo(() => {
|
const triggerPrimary = createMemo(() => {
|
||||||
const selected = currentValue()?.value
|
const selected = currentValue()?.value
|
||||||
return selected ? `Thinking: ${selected}` : "Thinking: Default"
|
const variant = selected ?? t("thinkingSelector.variant.default")
|
||||||
|
return t("thinkingSelector.label", { variant })
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,7 +72,7 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
|||||||
options={options()}
|
options={options()}
|
||||||
optionValue="key"
|
optionValue="key"
|
||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
placeholder="Thinking: Default"
|
placeholder={t("thinkingSelector.label", { variant: t("thinkingSelector.variant.default") })}
|
||||||
itemComponent={(itemProps) => (
|
itemComponent={(itemProps) => (
|
||||||
<Combobox.Item item={itemProps.item} class="selector-option">
|
<Combobox.Item item={itemProps.item} class="selector-option">
|
||||||
<div class="selector-option-content">
|
<div class="selector-option-content">
|
||||||
@@ -87,9 +92,6 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
|||||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
<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>
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="selector-trigger-hint" aria-hidden="true">
|
|
||||||
<Kbd shortcut="cmd+shift+t" />
|
|
||||||
</span>
|
|
||||||
<Combobox.Icon class="selector-trigger-icon">
|
<Combobox.Icon class="selector-trigger-icon">
|
||||||
<ChevronDown class="w-3 h-3" />
|
<ChevronDown class="w-3 h-3" />
|
||||||
</Combobox.Icon>
|
</Combobox.Icon>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQue
|
|||||||
import type { PermissionRequestLike } from "../types/permission"
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionSessionId } from "../types/permission"
|
import { getPermissionSessionId } from "../types/permission"
|
||||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||||
import { QuestionToolBlock } from "./tool-call/question-block"
|
import { QuestionToolBlock } from "./tool-call/question-block"
|
||||||
import { PermissionToolBlock } from "./tool-call/permission-block"
|
import { PermissionToolBlock } from "./tool-call/permission-block"
|
||||||
@@ -67,6 +68,7 @@ interface ToolCallProps {
|
|||||||
export default function ToolCall(props: ToolCallProps) {
|
export default function ToolCall(props: ToolCallProps) {
|
||||||
const { preferences, setDiffViewMode } = useConfig()
|
const { preferences, setDiffViewMode } = useConfig()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t } = useI18n()
|
||||||
const toolCallMemo = createMemo(() => props.toolCall)
|
const toolCallMemo = createMemo(() => props.toolCall)
|
||||||
const toolName = createMemo(() => toolCallMemo()?.tool || "")
|
const toolName = createMemo(() => toolCallMemo()?.tool || "")
|
||||||
const toolCallIdentifier = createMemo(() => {
|
const toolCallIdentifier = createMemo(() => {
|
||||||
@@ -233,12 +235,16 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
restoreScrollPosition(autoScroll())
|
restoreScrollPosition(autoScroll())
|
||||||
if (!expanded()) return
|
if (!expanded()) return
|
||||||
scheduleAnchorScroll()
|
scheduleAnchorScroll(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||||
scrollContainerRef = element || undefined
|
const next = element || undefined
|
||||||
|
if (next === scrollContainerRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollContainerRef = next
|
||||||
setScrollContainer(scrollContainerRef)
|
setScrollContainer(scrollContainerRef)
|
||||||
if (scrollContainerRef) {
|
if (scrollContainerRef) {
|
||||||
restoreScrollPosition(autoScroll())
|
restoreScrollPosition(autoScroll())
|
||||||
@@ -442,7 +448,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return row.map((value) => value.trim()).filter((value) => value.length > 0)
|
return row.map((value) => value.trim()).filter((value) => value.length > 0)
|
||||||
})
|
})
|
||||||
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
|
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
|
||||||
setQuestionError("Please answer all questions before submitting.")
|
setQuestionError(t("toolCall.question.validation.answerAll"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +459,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
|
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to send question reply", 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 {
|
} finally {
|
||||||
setQuestionSubmitting(false)
|
setQuestionSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -471,7 +477,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
await sendQuestionReject(props.instanceId, sessionId, request.id)
|
await sendQuestionReject(props.instanceId, sessionId, request.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to reject question", 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 {
|
} finally {
|
||||||
setQuestionSubmitting(false)
|
setQuestionSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -545,6 +551,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
preferences,
|
preferences,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
isDark,
|
isDark,
|
||||||
|
t,
|
||||||
diffCache,
|
diffCache,
|
||||||
permissionDiffCache,
|
permissionDiffCache,
|
||||||
scrollHelpers,
|
scrollHelpers,
|
||||||
@@ -568,6 +575,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
toolCall: toolCallMemo,
|
toolCall: toolCallMemo,
|
||||||
toolState,
|
toolState,
|
||||||
toolName,
|
toolName,
|
||||||
|
t,
|
||||||
messageVersion: messageVersionAccessor,
|
messageVersion: messageVersionAccessor,
|
||||||
partVersion: partVersionAccessor,
|
partVersion: partVersionAccessor,
|
||||||
renderMarkdown: renderMarkdownContent,
|
renderMarkdown: renderMarkdownContent,
|
||||||
@@ -589,7 +597,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousPartVersion = version
|
previousPartVersion = version
|
||||||
scheduleAnchorScroll()
|
scheduleAnchorScroll(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -639,7 +647,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
|
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to send permission response", 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 {
|
} finally {
|
||||||
setPermissionSubmitting(false)
|
setPermissionSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -651,7 +659,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (state.status === "error" && state.error) {
|
if (state.status === "error" && state.error) {
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-error-content">
|
<div class="tool-call-error-content">
|
||||||
<strong>Error:</strong> {state.error}
|
<strong>{t("toolCall.error.label")}</strong> {state.error}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -752,7 +760,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
<Show when={status() === "pending" && !pendingPermission()}>
|
<Show when={status() === "pending" && !pendingPermission()}>
|
||||||
<div class="tool-call-pending-message">
|
<div class="tool-call-pending-message">
|
||||||
<span class="spinner-small"></span>
|
<span class="spinner-small"></span>
|
||||||
<span>Waiting to run...</span>
|
<span>{t("toolCall.pending.waitingToRun")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -761,6 +769,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
<Show when={diagnosticsEntries().length}>
|
<Show when={diagnosticsEntries().length}>
|
||||||
|
|
||||||
{renderDiagnosticsSection(
|
{renderDiagnosticsSection(
|
||||||
|
t,
|
||||||
diagnosticsEntries(),
|
diagnosticsEntries(),
|
||||||
diagnosticsExpanded(),
|
diagnosticsExpanded(),
|
||||||
() => setDiagnosticsOverride((prev) => {
|
() => setDiagnosticsOverride((prev) => {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||||
{params.scrollHelpers.renderSentinel()}
|
{params.scrollHelpers.renderSentinel()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { For, Show } from "solid-js"
|
|||||||
import type { DiagnosticEntry } from "./diagnostics"
|
import type { DiagnosticEntry } from "./diagnostics"
|
||||||
|
|
||||||
export function renderDiagnosticsSection(
|
export function renderDiagnosticsSection(
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => string,
|
||||||
entries: DiagnosticEntry[],
|
entries: DiagnosticEntry[],
|
||||||
expanded: boolean,
|
expanded: boolean,
|
||||||
toggle: () => void,
|
toggle: () => void,
|
||||||
@@ -22,13 +23,13 @@ export function renderDiagnosticsSection(
|
|||||||
<span class="tool-call-emoji" aria-hidden="true">
|
<span class="tool-call-emoji" aria-hidden="true">
|
||||||
🛠
|
🛠
|
||||||
</span>
|
</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}>
|
<span class="tool-call-diagnostics-file" title={fileLabel}>
|
||||||
{fileLabel}
|
{fileLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<Show when={expanded}>
|
<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">
|
<div class="tool-call-diagnostics-body" role="list">
|
||||||
<For each={entries}>
|
<For each={entries}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
||||||
|
import { tGlobal } from "../../lib/i18n"
|
||||||
|
|
||||||
interface LspRangePosition {
|
interface LspRangePosition {
|
||||||
line?: number
|
line?: number
|
||||||
@@ -40,9 +41,9 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
if (tone === "error") return { label: tGlobal("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
||||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
if (tone === "warning") return { label: tGlobal("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
||||||
return { label: "INFO", icon: "i", rank: 2 }
|
return { label: tGlobal("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||||
|
|||||||
@@ -19,19 +19,32 @@ export function createDiffContentRenderer(params: {
|
|||||||
preferences: Accessor<DiffPrefs>
|
preferences: Accessor<DiffPrefs>
|
||||||
setDiffViewMode: (mode: DiffViewMode) => void
|
setDiffViewMode: (mode: DiffViewMode) => void
|
||||||
isDark: Accessor<boolean>
|
isDark: Accessor<boolean>
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => string
|
||||||
diffCache: CacheHandle
|
diffCache: CacheHandle
|
||||||
permissionDiffCache: CacheHandle
|
permissionDiffCache: CacheHandle
|
||||||
scrollHelpers: ToolScrollHelpers
|
scrollHelpers: ToolScrollHelpers
|
||||||
handleScrollRendered: () => void
|
handleScrollRendered: () => void
|
||||||
onContentRendered?: () => 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 {
|
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
|
||||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
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 selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||||
const themeKey = params.isDark() ? "dark" : "light"
|
const themeKey = params.isDark() ? "dark" : "light"
|
||||||
|
const disableScrollTracking = Boolean(options?.disableScrollTracking)
|
||||||
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
|
||||||
const baseEntryParams = cacheHandle.params() as any
|
const baseEntryParams = cacheHandle.params() as any
|
||||||
const cacheEntryParams = (() => {
|
const cacheEntryParams = (() => {
|
||||||
@@ -55,7 +68,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!options?.disableScrollTracking) {
|
if (!disableScrollTracking) {
|
||||||
params.handleScrollRendered()
|
params.handleScrollRendered()
|
||||||
}
|
}
|
||||||
params.onContentRendered?.()
|
params.onContentRendered?.()
|
||||||
@@ -64,10 +77,10 @@ export function createDiffContentRenderer(params: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
ref={registerRef}
|
||||||
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
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>
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||||
<div class="tool-call-diff-toggle">
|
<div class="tool-call-diff-toggle">
|
||||||
<button
|
<button
|
||||||
@@ -76,7 +89,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
aria-pressed={diffMode() === "split"}
|
aria-pressed={diffMode() === "split"}
|
||||||
onClick={() => handleModeChange("split")}
|
onClick={() => handleModeChange("split")}
|
||||||
>
|
>
|
||||||
Split
|
{params.t("toolCall.diff.viewMode.split")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -84,7 +97,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
aria-pressed={diffMode() === "unified"}
|
aria-pressed={diffMode() === "unified"}
|
||||||
onClick={() => handleModeChange("unified")}
|
onClick={() => handleModeChange("unified")}
|
||||||
>
|
>
|
||||||
Unified
|
{params.t("toolCall.diff.viewMode.unified")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +110,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
cacheEntryParams={cacheEntryParams as any}
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
onRendered={handleDiffRendered}
|
onRendered={handleDiffRendered}
|
||||||
/>
|
/>
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
handleScrollRendered: () => void
|
handleScrollRendered: () => void
|
||||||
onContentRendered?: () => 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 {
|
function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null {
|
||||||
if (!options.content) {
|
if (!options.content) {
|
||||||
return null
|
return null
|
||||||
@@ -24,6 +32,7 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
const disableHighlight = options.disableHighlight || false
|
const disableHighlight = options.disableHighlight || false
|
||||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||||
const disableScrollTracking = options.disableScrollTracking || false
|
const disableScrollTracking = options.disableScrollTracking || false
|
||||||
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
|
||||||
const state = params.toolState()
|
const state = params.toolState()
|
||||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||||
@@ -31,7 +40,7 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={messageClass}
|
class={messageClass}
|
||||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
ref={registerRef}
|
||||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||||
>
|
>
|
||||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||||
@@ -56,7 +65,7 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={messageClass}
|
class={messageClass}
|
||||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
ref={registerRef}
|
||||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||||
>
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Show, type Accessor, type JSXElement } from "solid-js"
|
|||||||
import type { PermissionRequestLike } from "../../types/permission"
|
import type { PermissionRequestLike } from "../../types/permission"
|
||||||
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
|
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
|
||||||
import { getPermissionSessionId } from "../../types/permission"
|
import { getPermissionSessionId } from "../../types/permission"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
import type { DiffPayload, DiffRenderOptions } from "./types"
|
import type { DiffPayload, DiffRenderOptions } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ export type PermissionToolBlockProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const diffPayload = () => {
|
const diffPayload = () => {
|
||||||
const permission = props.permission()
|
const permission = props.permission()
|
||||||
if (!permission) return null
|
if (!permission) return null
|
||||||
@@ -48,7 +51,9 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
|||||||
{(permission) => (
|
{(permission) => (
|
||||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||||
<div class="tool-call-permission-header">
|
<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>
|
<span class="tool-call-permission-type">{getPermissionKind(permission())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-call-permission-body">
|
<div class="tool-call-permission-body">
|
||||||
@@ -62,14 +67,14 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
|||||||
variant: "permission-diff",
|
variant: "permission-diff",
|
||||||
disableScrollTracking: true,
|
disableScrollTracking: true,
|
||||||
label: payload().filePath
|
label: payload().filePath
|
||||||
? `Requested diff · ${getRelativePath(payload().filePath || "")}`
|
? t("toolCall.permission.requestedDiff.withPath", { path: getRelativePath(payload().filePath || "") })
|
||||||
: "Requested diff",
|
: t("toolCall.permission.requestedDiff.label"),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!props.active()}>
|
<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>
|
</Show>
|
||||||
<div class="tool-call-permission-actions">
|
<div class="tool-call-permission-actions">
|
||||||
<div class="tool-call-permission-buttons">
|
<div class="tool-call-permission-buttons">
|
||||||
@@ -79,7 +84,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
|||||||
disabled={props.submitting()}
|
disabled={props.submitting()}
|
||||||
onClick={() => respond("once")}
|
onClick={() => respond("once")}
|
||||||
>
|
>
|
||||||
Allow Once
|
{t("toolCall.permission.actions.allowOnce")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -87,7 +92,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
|||||||
disabled={props.submitting()}
|
disabled={props.submitting()}
|
||||||
onClick={() => respond("always")}
|
onClick={() => respond("always")}
|
||||||
>
|
>
|
||||||
Always Allow
|
{t("toolCall.permission.actions.alwaysAllow")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -95,17 +100,17 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
|||||||
disabled={props.submitting()}
|
disabled={props.submitting()}
|
||||||
onClick={() => respond("reject")}
|
onClick={() => respond("reject")}
|
||||||
>
|
>
|
||||||
Deny
|
{t("toolCall.permission.actions.deny")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.active()}>
|
<Show when={props.active()}>
|
||||||
<div class="tool-call-permission-shortcuts">
|
<div class="tool-call-permission-shortcuts">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Allow once</span>
|
<span>{t("toolCall.permission.shortcuts.allowOnce")}</span>
|
||||||
<kbd class="kbd">A</kbd>
|
<kbd class="kbd">A</kbd>
|
||||||
<span>Always allow</span>
|
<span>{t("toolCall.permission.shortcuts.alwaysAllow")}</span>
|
||||||
<kbd class="kbd">D</kbd>
|
<kbd class="kbd">D</kbd>
|
||||||
<span>Deny</span>
|
<span>{t("toolCall.permission.shortcuts.deny")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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 { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
type QuestionOption = { label: string; description: string }
|
type QuestionOption = { label: string; description: string }
|
||||||
|
|
||||||
@@ -26,6 +27,15 @@ export type QuestionToolBlockProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
let firstInputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.active() && firstInputRef) {
|
||||||
|
firstInputRef.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const requestId = createMemo(() => {
|
const requestId = createMemo(() => {
|
||||||
const state = props.toolState()
|
const state = props.toolState()
|
||||||
const request = props.request()
|
const request = props.request()
|
||||||
@@ -163,9 +173,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||||
<div class="tool-call-permission-header">
|
<div class="tool-call-permission-header">
|
||||||
<span class="tool-call-permission-label">
|
<span class="tool-call-permission-label">
|
||||||
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
{props.active()
|
||||||
|
? t("toolCall.question.status.required")
|
||||||
|
: props.request()
|
||||||
|
? t("toolCall.question.status.queued")
|
||||||
|
: t("toolCall.question.status.questions")}
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-permission-type">
|
||||||
|
{questions().length === 1 ? t("toolCall.question.type.one") : t("toolCall.question.type.other")}
|
||||||
</span>
|
</span>
|
||||||
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-call-permission-body">
|
<div class="tool-call-permission-body">
|
||||||
@@ -186,10 +202,10 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<div class="text-xs">
|
<div class="text-xs">
|
||||||
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={multi()}>
|
<Show when={multi()}>
|
||||||
<div class="text-xs text-muted">Multiple</div>
|
<div class="text-xs text-muted">{t("toolCall.question.multiple")}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,7 +213,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
|
|
||||||
<div class="mt-3 flex flex-col gap-1">
|
<div class="mt-3 flex flex-col gap-1">
|
||||||
<For each={q?.options ?? []}>
|
<For each={q?.options ?? []}>
|
||||||
{(opt) => {
|
{(opt, optIndex) => {
|
||||||
const checked = () => selected().includes(opt.label)
|
const checked = () => selected().includes(opt.label)
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
@@ -205,6 +221,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
title={opt.description}
|
title={opt.description}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
if (i() === 0 && optIndex() === 0) firstInputRef = el
|
||||||
|
}}
|
||||||
type={inputType()}
|
type={inputType()}
|
||||||
name={groupName()}
|
name={groupName()}
|
||||||
checked={checked()}
|
checked={checked()}
|
||||||
@@ -222,9 +241,12 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
|
|
||||||
<label
|
<label
|
||||||
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
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
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
if (i() === 0 && (q?.options?.length ?? 0) === 0) firstInputRef = el
|
||||||
|
}}
|
||||||
type={inputType()}
|
type={inputType()}
|
||||||
name={groupName()}
|
name={groupName()}
|
||||||
checked={customChecked()}
|
checked={customChecked()}
|
||||||
@@ -244,11 +266,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
<div class="text-sm leading-tight">Custom answer</div>
|
<div class="text-sm leading-tight">{t("toolCall.question.custom.label")}</div>
|
||||||
<input
|
<input
|
||||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type your own answer"
|
placeholder={t("toolCall.question.custom.placeholder")}
|
||||||
disabled={!props.active() || props.submitting()}
|
disabled={!props.active() || props.submitting()}
|
||||||
value={customValue()}
|
value={customValue()}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
@@ -257,6 +279,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
toggleFromCustomInput(i(), e.currentTarget)
|
toggleFromCustomInput(i(), e.currentTarget)
|
||||||
}}
|
}}
|
||||||
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.isComposing) {
|
||||||
|
if (!submitDisabled()) {
|
||||||
|
props.onSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -275,7 +304,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
disabled={submitDisabled()}
|
disabled={submitDisabled()}
|
||||||
onClick={() => props.onSubmit()}
|
onClick={() => props.onSubmit()}
|
||||||
>
|
>
|
||||||
Submit
|
{t("toolCall.question.actions.submit")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -283,15 +312,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
disabled={props.submitting()}
|
disabled={props.submitting()}
|
||||||
onClick={() => props.onDismiss()}
|
onClick={() => props.onDismiss()}
|
||||||
>
|
>
|
||||||
Dismiss
|
{t("toolCall.question.actions.dismiss")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-call-permission-shortcuts">
|
<div class="tool-call-permission-shortcuts">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Submit</span>
|
<span>{t("toolCall.question.shortcuts.submit")}</span>
|
||||||
<kbd class="kbd">Esc</kbd>
|
<kbd class="kbd">Esc</kbd>
|
||||||
<span>Dismiss</span>
|
<span>{t("toolCall.question.shortcuts.dismiss")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.error()}>
|
<Show when={props.error()}>
|
||||||
@@ -301,7 +330,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!props.active() && props.request()}>
|
<Show when={!props.active() && props.request()}>
|
||||||
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
<p class="tool-call-permission-queued-text">{t("toolCall.question.queuedText")}</p>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
|||||||
return "info"
|
return "info"
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
|
||||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
||||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
||||||
return { label: "INFO", icon: "i", rank: 2 }
|
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDiagnosticsKey(
|
function resolveDiagnosticsKey(
|
||||||
@@ -69,6 +69,7 @@ function resolveDiagnosticsKey(
|
|||||||
function buildDiagnostics(
|
function buildDiagnostics(
|
||||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||||
file: ApplyPatchFile,
|
file: ApplyPatchFile,
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => string,
|
||||||
): DiagnosticEntry[] {
|
): DiagnosticEntry[] {
|
||||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
const key = resolveDiagnosticsKey(diagnostics, file)
|
||||||
if (!key) return []
|
if (!key) return []
|
||||||
@@ -82,7 +83,7 @@ function buildDiagnostics(
|
|||||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||||
|
|
||||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
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 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
|
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)
|
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 (
|
return (
|
||||||
<Show when={props.entries.length > 0}>
|
<Show when={props.entries.length > 0}>
|
||||||
<div class="tool-call-diagnostics-wrapper">
|
<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">
|
<div class="tool-call-diagnostics-body" role="list">
|
||||||
<For each={props.entries}>
|
<For each={props.entries}>
|
||||||
@@ -134,19 +138,22 @@ function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string })
|
|||||||
|
|
||||||
export const applyPatchRenderer: ToolRenderer = {
|
export const applyPatchRenderer: ToolRenderer = {
|
||||||
tools: ["apply_patch"],
|
tools: ["apply_patch"],
|
||||||
getAction: () => "Preparing apply_patch...",
|
getAction: ({ t }) => t("toolCall.applyPatch.action.preparing"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState, t }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
if (state.status === "pending") return getToolName("apply_patch")
|
if (state.status === "pending") return getToolName("apply_patch")
|
||||||
const { metadata } = readToolStatePayload(state)
|
const { metadata } = readToolStatePayload(state)
|
||||||
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
|
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
|
||||||
if (files.length > 0) {
|
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")
|
return getToolName("apply_patch")
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderDiff, renderMarkdown }) {
|
renderBody({ toolState, renderDiff, renderMarkdown, t }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state || state.status === "pending") return null
|
if (!state || state.status === "pending") return null
|
||||||
|
|
||||||
@@ -170,10 +177,10 @@ export const applyPatchRenderer: ToolRenderer = {
|
|||||||
<div class="tool-call-apply-patch">
|
<div class="tool-call-apply-patch">
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file, index) => {
|
{(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 diffText = typeof file.diff === "string" ? file.diff : ""
|
||||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file))
|
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-apply-patch-file">
|
<div class="tool-call-apply-patch-file">
|
||||||
@@ -181,12 +188,12 @@ export const applyPatchRenderer: ToolRenderer = {
|
|||||||
{renderDiff(
|
{renderDiff(
|
||||||
{ diffText, filePath },
|
{ diffText, filePath },
|
||||||
{
|
{
|
||||||
label: `Diff · ${getRelativePath(labelBase)}`,
|
label: t("toolCall.diff.label.withPath", { path: getRelativePath(labelBase) }),
|
||||||
cacheKey: `apply_patch:${labelBase}:${index()}`,
|
cacheKey: `apply_patch:${labelBase}:${index()}`,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<DiagnosticsInline entries={entries()} label={labelBase} />
|
<DiagnosticsInline entries={entries()} label={labelBase} t={t} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||||
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
|
||||||
export const bashRenderer: ToolRenderer = {
|
export const bashRenderer: ToolRenderer = {
|
||||||
tools: ["bash"],
|
tools: ["bash"],
|
||||||
getAction: () => "Writing command...",
|
getAction: () => tGlobal("toolCall.renderer.action.writingCommand"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
@@ -18,7 +19,7 @@ export const bashRenderer: ToolRenderer = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeoutLabel = `${timeout}ms`
|
const timeoutLabel = `${timeout}ms`
|
||||||
return `${baseTitle} · Timeout: ${timeoutLabel}`
|
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||||
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
|
||||||
export const editRenderer: ToolRenderer = {
|
export const editRenderer: ToolRenderer = {
|
||||||
tools: ["edit"],
|
tools: ["edit"],
|
||||||
getAction: () => "Preparing edit...",
|
getAction: () => tGlobal("toolCall.renderer.action.preparingEdit"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||||
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
|
||||||
export const patchRenderer: ToolRenderer = {
|
export const patchRenderer: ToolRenderer = {
|
||||||
tools: ["patch"],
|
tools: ["patch"],
|
||||||
getAction: () => "Preparing patch...",
|
getAction: () => tGlobal("toolCall.renderer.action.preparingPatch"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import type { ToolRenderer } from "../types"
|
|||||||
|
|
||||||
export const questionRenderer: ToolRenderer = {
|
export const questionRenderer: ToolRenderer = {
|
||||||
tools: ["question"],
|
tools: ["question"],
|
||||||
getAction: () => "Awaiting answers...",
|
getAction: ({ t }) => t("toolCall.question.action.awaitingAnswers"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState, t }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return "Questions"
|
if (!state) return t("toolCall.question.title.questions")
|
||||||
if (state.status === "completed") return "Questions"
|
if (state.status === "completed") return t("toolCall.question.title.questions")
|
||||||
return "Asking questions"
|
return t("toolCall.question.title.askingQuestions")
|
||||||
},
|
},
|
||||||
renderBody() {
|
renderBody() {
|
||||||
// The question tool UI is rendered by ToolCall itself so
|
// The question tool UI is rendered by ToolCall itself so
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||||
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
|
||||||
export const readRenderer: ToolRenderer = {
|
export const readRenderer: ToolRenderer = {
|
||||||
tools: ["read"],
|
tools: ["read"],
|
||||||
getAction: () => "Reading file...",
|
getAction: () => tGlobal("toolCall.renderer.action.readingFile"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
@@ -15,11 +16,11 @@ export const readRenderer: ToolRenderer = {
|
|||||||
const detailParts: string[] = []
|
const detailParts: string[] = []
|
||||||
|
|
||||||
if (typeof offset === "number") {
|
if (typeof offset === "number") {
|
||||||
detailParts.push(`Offset: ${offset}`)
|
detailParts.push(tGlobal("toolCall.renderer.read.detail.offset", { offset }))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof limit === "number") {
|
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")
|
const baseTitle = relativePath ? `${getToolName("read")} ${relativePath}` : getToolName("read")
|
||||||
|
|||||||
@@ -37,18 +37,7 @@ function summarizeStatusIcon(status?: ToolState["status"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarizeStatusLabel(status?: ToolState["status"]) {
|
function summarizeStatusLabel(status?: ToolState["status"]) {
|
||||||
switch (status) {
|
return status
|
||||||
case "pending":
|
|
||||||
return "Pending"
|
|
||||||
case "running":
|
|
||||||
return "Running"
|
|
||||||
case "completed":
|
|
||||||
return "Completed"
|
|
||||||
case "error":
|
|
||||||
return "Error"
|
|
||||||
default:
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeTaskTitle(input: Record<string, any>) {
|
function describeTaskTitle(input: Record<string, any>) {
|
||||||
@@ -82,14 +71,14 @@ function describeToolTitle(item: TaskSummaryItem): string {
|
|||||||
|
|
||||||
export const taskRenderer: ToolRenderer = {
|
export const taskRenderer: ToolRenderer = {
|
||||||
tools: ["task"],
|
tools: ["task"],
|
||||||
getAction: () => "Delegating...",
|
getAction: ({ t }) => t("toolCall.task.action.delegating"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
return describeTaskTitle(input)
|
||||||
},
|
},
|
||||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown }) {
|
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||||
const promptContent = createMemo(() => {
|
const promptContent = createMemo(() => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return null
|
if (!state) return null
|
||||||
@@ -128,9 +117,9 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const headerMeta = createMemo(() => {
|
const headerMeta = createMemo(() => {
|
||||||
const agent = agentLabel()
|
const agent = agentLabel()
|
||||||
const model = modelLabel()
|
const model = modelLabel()
|
||||||
if (agent && model) return `Agent: ${agent} • Model: ${model}`
|
if (agent && model) return t("toolCall.task.meta.agentModel", { agent, model })
|
||||||
if (agent) return `Agent: ${agent}`
|
if (agent) return t("toolCall.task.meta.agent", { agent })
|
||||||
if (model) return `Model: ${model}`
|
if (model) return t("toolCall.task.meta.model", { model })
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -162,7 +151,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
<Show when={promptContent()}>
|
<Show when={promptContent()}>
|
||||||
<section class="tool-call-task-section">
|
<section class="tool-call-task-section">
|
||||||
<header class="tool-call-task-section-header">
|
<header class="tool-call-task-section-header">
|
||||||
<span class="tool-call-task-section-title">Prompt</span>
|
<span class="tool-call-task-section-title">{t("toolCall.task.sections.prompt")}</span>
|
||||||
<Show when={headerMeta()}>
|
<Show when={headerMeta()}>
|
||||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -181,13 +170,13 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
<Show when={items().length > 0}>
|
<Show when={items().length > 0}>
|
||||||
<section class="tool-call-task-section">
|
<section class="tool-call-task-section">
|
||||||
<header class="tool-call-task-section-header">
|
<header class="tool-call-task-section-header">
|
||||||
<span class="tool-call-task-section-title">Steps</span>
|
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
|
||||||
<span class="tool-call-task-section-meta">{items().length} steps</span>
|
<span class="tool-call-task-section-meta">{t("toolCall.task.steps.count", { count: items().length })}</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="tool-call-task-section-body">
|
<div class="tool-call-task-section-body">
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-task-container"
|
class="message-text tool-call-markdown tool-call-task-container"
|
||||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
ref={scrollHelpers?.registerContainer}
|
||||||
onScroll={
|
onScroll={
|
||||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||||
}
|
}
|
||||||
@@ -200,7 +189,10 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const toolLabel = getToolName(item.tool)
|
const toolLabel = getToolName(item.tool)
|
||||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||||
const statusIcon = summarizeStatusIcon(status)
|
const statusIcon = summarizeStatusIcon(status)
|
||||||
const statusLabel = summarizeStatusLabel(status)
|
const statusKey = summarizeStatusLabel(status)
|
||||||
|
const statusLabel = statusKey
|
||||||
|
? t(`toolCall.status.${statusKey}`)
|
||||||
|
: t("toolCall.status.unknown")
|
||||||
const statusAttr = status ?? "pending"
|
const statusAttr = status ?? "pending"
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||||
@@ -227,7 +219,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
<Show when={outputContent()}>
|
<Show when={outputContent()}>
|
||||||
<section class="tool-call-task-section">
|
<section class="tool-call-task-section">
|
||||||
<header class="tool-call-task-section-header">
|
<header class="tool-call-task-section-header">
|
||||||
<span class="tool-call-task-section-title">Output</span>
|
<span class="tool-call-task-section-title">{t("toolCall.task.sections.output")}</span>
|
||||||
<Show when={headerMeta()}>
|
<Show when={headerMeta()}>
|
||||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { For, Show } from "solid-js"
|
|||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { readToolStatePayload } from "../utils"
|
import { readToolStatePayload } from "../utils"
|
||||||
|
import { useI18n, tGlobal } from "../../../lib/i18n"
|
||||||
|
|
||||||
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
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) {
|
switch (status) {
|
||||||
case "completed":
|
case "completed":
|
||||||
return "Completed"
|
return t("toolCall.renderer.todo.status.completed")
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
return "In progress"
|
return t("toolCall.renderer.todo.status.inProgress")
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return "Cancelled"
|
return t("toolCall.renderer.todo.status.cancelled")
|
||||||
default:
|
default:
|
||||||
return "Pending"
|
return t("toolCall.renderer.todo.status.pending")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +66,12 @@ interface TodoListViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TodoListView(props: TodoListViewProps) {
|
export function TodoListView(props: TodoListViewProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const todos = extractTodosFromState(props.state)
|
const todos = extractTodosFromState(props.state)
|
||||||
const counts = summarizeTodos(todos)
|
const counts = summarizeTodos(todos)
|
||||||
|
|
||||||
if (counts.total === 0) {
|
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 (
|
return (
|
||||||
@@ -77,7 +79,7 @@ export function TodoListView(props: TodoListViewProps) {
|
|||||||
<div class="tool-call-todos" role="list">
|
<div class="tool-call-todos" role="list">
|
||||||
<For each={todos}>
|
<For each={todos}>
|
||||||
{(todo) => {
|
{(todo) => {
|
||||||
const label = getTodoStatusLabel(todo.status)
|
const label = getTodoStatusLabel(t, todo.status)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="tool-call-todo-item"
|
class="tool-call-todo-item"
|
||||||
@@ -108,20 +110,20 @@ export function TodoListView(props: TodoListViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTodoTitle(state?: ToolState): string {
|
export function getTodoTitle(state?: ToolState): string {
|
||||||
if (!state) return "Plan"
|
if (!state) return tGlobal("toolCall.renderer.todo.title.plan")
|
||||||
|
|
||||||
const todos = extractTodosFromState(state)
|
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)
|
const counts = summarizeTodos(todos)
|
||||||
if (counts.pending === counts.total) return "Creating plan"
|
if (counts.pending === counts.total) return tGlobal("toolCall.renderer.todo.title.creating")
|
||||||
if (counts.completed === counts.total) return "Completing plan"
|
if (counts.completed === counts.total) return tGlobal("toolCall.renderer.todo.title.completing")
|
||||||
return "Updating plan"
|
return tGlobal("toolCall.renderer.todo.title.updating")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const todoRenderer: ToolRenderer = {
|
export const todoRenderer: ToolRenderer = {
|
||||||
tools: ["todowrite", "todoread"],
|
tools: ["todowrite", "todoread"],
|
||||||
getAction: () => "Planning...",
|
getAction: () => tGlobal("toolCall.renderer.action.planning"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
return getTodoTitle(toolState())
|
return getTodoTitle(toolState())
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils"
|
||||||
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
|
||||||
export const webfetchRenderer: ToolRenderer = {
|
export const webfetchRenderer: ToolRenderer = {
|
||||||
tools: ["webfetch"],
|
tools: ["webfetch"],
|
||||||
getAction: () => "Fetching from the web...",
|
getAction: () => tGlobal("toolCall.renderer.action.fetchingFromWeb"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||||
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
|
||||||
export const writeRenderer: ToolRenderer = {
|
export const writeRenderer: ToolRenderer = {
|
||||||
tools: ["write"],
|
tools: ["write"],
|
||||||
getAction: () => "Preparing write...",
|
getAction: () => tGlobal("toolCall.renderer.action.preparingWrite"),
|
||||||
getTitle({ toolState }) {
|
getTitle({ toolState }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||||
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||||
|
import { enMessages } from "../../lib/i18n/messages/en"
|
||||||
import { defaultRenderer } from "./renderers/default"
|
import { defaultRenderer } from "./renderers/default"
|
||||||
import { bashRenderer } from "./renderers/bash"
|
import { bashRenderer } from "./renderers/bash"
|
||||||
import { readRenderer } from "./renderers/read"
|
import { readRenderer } from "./renderers/read"
|
||||||
@@ -43,12 +44,28 @@ function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
|
|||||||
} as 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 {
|
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||||
const toolStateAccessor = () => snapshot.state
|
const toolStateAccessor = () => snapshot.state
|
||||||
const toolNameAccessor = () => snapshot.toolName
|
const toolNameAccessor = () => snapshot.toolName
|
||||||
const toolCallAccessor = () => createStaticToolPart(snapshot)
|
const toolCallAccessor = () => createStaticToolPart(snapshot)
|
||||||
const messageVersionAccessor = () => undefined
|
const messageVersionAccessor = () => undefined
|
||||||
const partVersionAccessor = () => undefined
|
const partVersionAccessor = () => undefined
|
||||||
|
const t = createStaticT()
|
||||||
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
|
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
|
||||||
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
|
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
|
||||||
const renderDiff: ToolRendererContext["renderDiff"] = () => null
|
const renderDiff: ToolRendererContext["renderDiff"] = () => null
|
||||||
@@ -57,6 +74,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
|||||||
toolCall: toolCallAccessor,
|
toolCall: toolCallAccessor,
|
||||||
toolState: toolStateAccessor,
|
toolState: toolStateAccessor,
|
||||||
toolName: toolNameAccessor,
|
toolName: toolNameAccessor,
|
||||||
|
t,
|
||||||
messageVersion: messageVersionAccessor,
|
messageVersion: messageVersionAccessor,
|
||||||
partVersion: partVersionAccessor,
|
partVersion: partVersionAccessor,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface ToolRendererContext {
|
|||||||
toolCall: Accessor<ToolCallPart>
|
toolCall: Accessor<ToolCallPart>
|
||||||
toolState: Accessor<ToolState | undefined>
|
toolState: Accessor<ToolState | undefined>
|
||||||
toolName: Accessor<string>
|
toolName: Accessor<string>
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => string
|
||||||
messageVersion?: Accessor<number | undefined>
|
messageVersion?: Accessor<number | undefined>
|
||||||
partVersion?: Accessor<number | undefined>
|
partVersion?: Accessor<number | undefined>
|
||||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getLanguageFromPath } from "../../lib/markdown"
|
|||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { DiffPayload } from "./types"
|
import type { DiffPayload } from "./types"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
|
import { tGlobal } from "../../lib/i18n"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -61,16 +62,16 @@ export function getToolIcon(tool: string): string {
|
|||||||
export function getToolName(tool: string): string {
|
export function getToolName(tool: string): string {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "bash":
|
case "bash":
|
||||||
return "Shell"
|
return tGlobal("toolCall.renderer.toolName.shell")
|
||||||
case "webfetch":
|
case "webfetch":
|
||||||
return "Fetch"
|
return tGlobal("toolCall.renderer.toolName.fetch")
|
||||||
case "invalid":
|
case "invalid":
|
||||||
return "Invalid"
|
return tGlobal("toolCall.renderer.toolName.invalid")
|
||||||
case "todowrite":
|
case "todowrite":
|
||||||
case "todoread":
|
case "todoread":
|
||||||
return "Plan"
|
return tGlobal("toolCall.renderer.toolName.plan")
|
||||||
case "apply_patch":
|
case "apply_patch":
|
||||||
return "Apply patch"
|
return tGlobal("toolCall.renderer.toolName.applyPatch")
|
||||||
default: {
|
default: {
|
||||||
const normalized = tool.replace(/^opencode_/, "")
|
const normalized = tool.replace(/^opencode_/, "")
|
||||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||||
@@ -202,31 +203,31 @@ export function readToolStatePayload(state?: ToolState): {
|
|||||||
export function getDefaultToolAction(toolName: string) {
|
export function getDefaultToolAction(toolName: string) {
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case "task":
|
case "task":
|
||||||
return "Delegating..."
|
return tGlobal("toolCall.task.action.delegating")
|
||||||
case "bash":
|
case "bash":
|
||||||
return "Writing command..."
|
return tGlobal("toolCall.renderer.action.writingCommand")
|
||||||
case "edit":
|
case "edit":
|
||||||
return "Preparing edit..."
|
return tGlobal("toolCall.renderer.action.preparingEdit")
|
||||||
case "webfetch":
|
case "webfetch":
|
||||||
return "Fetching from the web..."
|
return tGlobal("toolCall.renderer.action.fetchingFromWeb")
|
||||||
case "glob":
|
case "glob":
|
||||||
return "Finding files..."
|
return tGlobal("toolCall.renderer.action.findingFiles")
|
||||||
case "grep":
|
case "grep":
|
||||||
return "Searching content..."
|
return tGlobal("toolCall.renderer.action.searchingContent")
|
||||||
case "list":
|
case "list":
|
||||||
return "Listing directory..."
|
return tGlobal("toolCall.renderer.action.listingDirectory")
|
||||||
case "read":
|
case "read":
|
||||||
return "Reading file..."
|
return tGlobal("toolCall.renderer.action.readingFile")
|
||||||
case "write":
|
case "write":
|
||||||
return "Preparing write..."
|
return tGlobal("toolCall.renderer.action.preparingWrite")
|
||||||
case "todowrite":
|
case "todowrite":
|
||||||
case "todoread":
|
case "todoread":
|
||||||
return "Planning..."
|
return tGlobal("toolCall.renderer.action.planning")
|
||||||
case "patch":
|
case "patch":
|
||||||
return "Preparing patch..."
|
return tGlobal("toolCall.renderer.action.preparingPatch")
|
||||||
case "apply_patch":
|
case "apply_patch":
|
||||||
return "Preparing apply_patch..."
|
return tGlobal("toolCall.applyPatch.action.preparing")
|
||||||
default:
|
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 { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ interface UnifiedPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const mode = () => props.mode ?? "mention"
|
const mode = () => props.mode ?? "mention"
|
||||||
|
|
||||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||||
@@ -366,10 +368,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
const isLoading = () => mode() === "mention" && loadingState() !== "idle"
|
const isLoading = () => mode() === "mention" && loadingState() !== "idle"
|
||||||
const loadingMessage = () => {
|
const loadingMessage = () => {
|
||||||
if (loadingState() === "search") {
|
if (loadingState() === "search") {
|
||||||
return "Searching..."
|
return t("unifiedPicker.loading.searching")
|
||||||
}
|
}
|
||||||
if (loadingState() === "listing") {
|
if (loadingState() === "listing") {
|
||||||
return "Loading workspace..."
|
return t("unifiedPicker.loading.loadingWorkspace")
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -383,8 +385,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="dropdown-header">
|
<div class="dropdown-header">
|
||||||
<div class="dropdown-header-title">
|
<div class="dropdown-header-title">
|
||||||
<Show when={mode() === "command"} fallback={"Select Agent or File"}>
|
<Show when={mode() === "command"} fallback={t("unifiedPicker.title.mention")}>
|
||||||
Select Command
|
{t("unifiedPicker.title.command")}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isLoading()}>
|
<Show when={isLoading()}>
|
||||||
<span class="ml-2">{loadingMessage()}</span>
|
<span class="ml-2">{loadingMessage()}</span>
|
||||||
@@ -394,11 +396,11 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
|
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
|
||||||
<Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}>
|
<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>
|
||||||
|
|
||||||
<Show when={mode() === "command" && commandCount() > 0}>
|
<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()}>
|
<For each={filteredCommands()}>
|
||||||
{(command) => {
|
{(command) => {
|
||||||
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
|
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}>
|
<Show when={mode() === "mention" && agentCount() > 0}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
AGENTS
|
{t("unifiedPicker.sections.agents")}
|
||||||
</div>
|
</div>
|
||||||
<For each={filteredAgents()}>
|
<For each={filteredAgents()}>
|
||||||
{(agent) => {
|
{(agent) => {
|
||||||
@@ -463,7 +465,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
<span class="text-sm font-medium">{agent.name}</span>
|
<span class="text-sm font-medium">{agent.name}</span>
|
||||||
<Show when={agent.mode === "subagent"}>
|
<Show when={agent.mode === "subagent"}>
|
||||||
<span class="dropdown-badge">
|
<span class="dropdown-badge">
|
||||||
subagent
|
{t("unifiedPicker.badge.subagent")}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,7 +486,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
<Show when={mode() === "mention" && fileCount() > 0}>
|
<Show when={mode() === "mention" && fileCount() > 0}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
FILES
|
{t("unifiedPicker.sections.files")}
|
||||||
</div>
|
</div>
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
@@ -534,8 +536,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
<div class="dropdown-footer">
|
<div class="dropdown-footer">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Tab/Enter</span> select •{" "}
|
<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> close
|
<span class="font-medium">Esc</span> {t("unifiedPicker.footer.close")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Show, createEffect, createSignal } from "solid-js"
|
import { Show, createEffect, createSignal } from "solid-js"
|
||||||
import type { ServerMeta } from "../../../server/src/api-types"
|
import type { ServerMeta } from "../../../server/src/api-types"
|
||||||
import { getServerMeta } from "../lib/server-meta"
|
import { getServerMeta } from "../lib/server-meta"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
export default function VersionPill() {
|
export default function VersionPill() {
|
||||||
|
const { t } = useI18n()
|
||||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -15,11 +17,13 @@ export default function VersionPill() {
|
|||||||
const uiVersion = () => meta()?.ui?.version
|
const uiVersion = () => meta()?.ui?.version
|
||||||
const uiSource = () => meta()?.ui?.source
|
const uiSource = () => meta()?.ui?.source
|
||||||
|
|
||||||
|
const uiLabel = () => (uiVersion() ? t("versionPill.uiWithVersion", { version: uiVersion() }) : t("versionPill.ui"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={serverVersion() || uiVersion() || uiSource()}>
|
<Show when={serverVersion() || uiVersion() || uiSource()}>
|
||||||
<div class="text-[11px] text-muted whitespace-nowrap">
|
<div class="text-[11px] text-muted whitespace-nowrap">
|
||||||
<Show when={serverVersion()}>
|
<Show when={serverVersion()}>
|
||||||
{(v) => <span>App {v()}</span>}
|
{(v) => <span>{t("versionPill.appWithVersion", { version: v() })}</span>}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={uiVersion() || uiSource()}>
|
<Show when={uiVersion() || uiSource()}>
|
||||||
<>
|
<>
|
||||||
@@ -27,8 +31,8 @@ export default function VersionPill() {
|
|||||||
<span class="mx-2">·</span>
|
<span class="mx-2">·</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span>
|
<span>
|
||||||
UI{uiVersion() ? ` ${uiVersion()}` : ""}
|
{uiLabel()}
|
||||||
<Show when={uiSource()}>{(s) => <span class="opacity-70"> ({s()})</span>}</Show>
|
<Show when={uiSource()}>{(s) => <span class="opacity-70">{t("versionPill.source", { source: s() })}</span>}</Show>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
|||||||
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||||
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
|
import { tGlobal } from "./i18n"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -17,19 +18,19 @@ export async function promptForCommandArguments(command: SDKCommand): Promise<st
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await showPromptDialog(`Arguments for /${command.name}`, {
|
return await showPromptDialog(tGlobal("commands.custom.argumentsPrompt.message", { name: command.name }), {
|
||||||
title: "Custom command",
|
title: tGlobal("commands.custom.argumentsPrompt.title"),
|
||||||
variant: "info",
|
variant: "info",
|
||||||
inputLabel: "Arguments",
|
inputLabel: tGlobal("commands.custom.argumentsPrompt.inputLabel"),
|
||||||
inputPlaceholder: "e.g. foo bar",
|
inputPlaceholder: tGlobal("commands.custom.argumentsPrompt.inputPlaceholder"),
|
||||||
inputDefaultValue: "",
|
inputDefaultValue: "",
|
||||||
confirmLabel: "Run",
|
confirmLabel: tGlobal("commands.custom.argumentsPrompt.confirmLabel"),
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: tGlobal("commands.custom.argumentsPrompt.cancelLabel"),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to prompt for command arguments", error)
|
log.error("Failed to prompt for command arguments", error)
|
||||||
showAlertDialog("Failed to open arguments prompt.", {
|
showAlertDialog(tGlobal("commands.custom.argumentsPrompt.openFailed.message"), {
|
||||||
title: "Command arguments",
|
title: tGlobal("commands.custom.argumentsPrompt.openFailed.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
return null
|
return null
|
||||||
@@ -45,14 +46,14 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
|
|||||||
return commands.map((cmd) => ({
|
return commands.map((cmd) => ({
|
||||||
id: `custom:${instanceId}:${cmd.name}`,
|
id: `custom:${instanceId}:${cmd.name}`,
|
||||||
label: formatCommandLabel(cmd.name),
|
label: formatCommandLabel(cmd.name),
|
||||||
description: cmd.description ?? "Custom command",
|
description: () => cmd.description ?? tGlobal("commands.custom.entries.descriptionFallback"),
|
||||||
category: "Custom Commands",
|
category: "Custom Commands",
|
||||||
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
|
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const sessionId = activeSessionId().get(instanceId)
|
const sessionId = activeSessionId().get(instanceId)
|
||||||
if (!sessionId || sessionId === "info") {
|
if (!sessionId || sessionId === "info") {
|
||||||
showAlertDialog("Select a session before running a custom command.", {
|
showAlertDialog(tGlobal("commands.custom.sessionRequired.message"), {
|
||||||
title: "Session required",
|
title: tGlobal("commands.custom.sessionRequired.title"),
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -65,8 +66,8 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
|
|||||||
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to run custom command", error)
|
log.error("Failed to run custom command", error)
|
||||||
showAlertDialog("Failed to run custom command. Check the console for details.", {
|
showAlertDialog(tGlobal("commands.custom.runFailed.message"), {
|
||||||
title: "Command failed",
|
title: tGlobal("commands.custom.runFailed.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ export interface KeyboardShortcut {
|
|||||||
alt?: boolean
|
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 {
|
export interface Command {
|
||||||
id: string
|
id: string
|
||||||
label: string | (() => string)
|
label: Resolvable<string>
|
||||||
description: string
|
description: Resolvable<string>
|
||||||
keywords?: string[]
|
keywords?: Resolvable<string[]>
|
||||||
shortcut?: KeyboardShortcut
|
shortcut?: KeyboardShortcut
|
||||||
action: () => void | Promise<void>
|
action: () => void | Promise<void>
|
||||||
category?: string
|
category?: Resolvable<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCommandRegistry() {
|
export function createCommandRegistry() {
|
||||||
@@ -47,11 +53,15 @@ export function createCommandRegistry() {
|
|||||||
|
|
||||||
const lowerQuery = query.toLowerCase()
|
const lowerQuery = query.toLowerCase()
|
||||||
return getAll().filter((cmd) => {
|
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 labelMatch = label.toLowerCase().includes(lowerQuery)
|
||||||
const descMatch = cmd.description.toLowerCase().includes(lowerQuery)
|
const descMatch = description.toLowerCase().includes(lowerQuery)
|
||||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
|
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
|
||||||
return labelMatch || descMatch || keywordMatch
|
const categoryMatch = category?.toLowerCase().includes(lowerQuery)
|
||||||
|
return labelMatch || descMatch || keywordMatch || categoryMatch
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ import { cleanupBlankSessions } from "../../stores/session-state"
|
|||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
import { requestData } from "../opencode-api"
|
import { requestData } from "../opencode-api"
|
||||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
|
import { tGlobal } from "../i18n"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
function splitKeywords(key: string): string[] {
|
||||||
|
return tGlobal(key)
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
@@ -61,20 +69,20 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "new-instance",
|
id: "new-instance",
|
||||||
label: "New Instance",
|
label: () => tGlobal("commands.newInstance.label"),
|
||||||
description: "Open folder picker to create new instance",
|
description: () => tGlobal("commands.newInstance.description"),
|
||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: ["folder", "project", "workspace"],
|
keywords: () => splitKeywords("commands.newInstance.keywords"),
|
||||||
shortcut: { key: "N", meta: true },
|
shortcut: { key: "N", meta: true },
|
||||||
action: options.handleNewInstanceRequest,
|
action: options.handleNewInstanceRequest,
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "close-instance",
|
id: "close-instance",
|
||||||
label: "Close Instance",
|
label: () => tGlobal("commands.closeInstance.label"),
|
||||||
description: "Stop current instance's server",
|
description: () => tGlobal("commands.closeInstance.description"),
|
||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: ["stop", "quit", "close"],
|
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
||||||
shortcut: { key: "W", meta: true },
|
shortcut: { key: "W", meta: true },
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -85,10 +93,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "instance-next",
|
id: "instance-next",
|
||||||
label: "Next Instance",
|
label: () => tGlobal("commands.nextInstance.label"),
|
||||||
description: "Cycle to next instance tab",
|
description: () => tGlobal("commands.nextInstance.description"),
|
||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: ["switch", "navigate"],
|
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
||||||
shortcut: { key: "]", meta: true },
|
shortcut: { key: "]", meta: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const ids = Array.from(instances().keys())
|
const ids = Array.from(instances().keys())
|
||||||
@@ -101,10 +109,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "instance-prev",
|
id: "instance-prev",
|
||||||
label: "Previous Instance",
|
label: () => tGlobal("commands.previousInstance.label"),
|
||||||
description: "Cycle to previous instance tab",
|
description: () => tGlobal("commands.previousInstance.description"),
|
||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: ["switch", "navigate"],
|
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
||||||
shortcut: { key: "[", meta: true },
|
shortcut: { key: "[", meta: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const ids = Array.from(instances().keys())
|
const ids = Array.from(instances().keys())
|
||||||
@@ -117,10 +125,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "new-session",
|
id: "new-session",
|
||||||
label: "New Session",
|
label: () => tGlobal("commands.newSession.label"),
|
||||||
description: "Create a new parent session",
|
description: () => tGlobal("commands.newSession.description"),
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["create", "start"],
|
keywords: () => splitKeywords("commands.newSession.keywords"),
|
||||||
shortcut: { key: "N", meta: true, shift: true },
|
shortcut: { key: "N", meta: true, shift: true },
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -131,10 +139,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "close-session",
|
id: "close-session",
|
||||||
label: "Close Session",
|
label: () => tGlobal("commands.closeSession.label"),
|
||||||
description: "Close current parent session",
|
description: () => tGlobal("commands.closeSession.description"),
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["close", "stop"],
|
keywords: () => splitKeywords("commands.closeSession.keywords"),
|
||||||
shortcut: { key: "W", meta: true, shift: true },
|
shortcut: { key: "W", meta: true, shift: true },
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -146,10 +154,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "cleanup-blank-sessions",
|
id: "cleanup-blank-sessions",
|
||||||
label: "Scrub Sessions",
|
label: () => tGlobal("commands.scrubSessions.label"),
|
||||||
description: "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
|
description: () => tGlobal("commands.scrubSessions.description"),
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["cleanup", "blank", "empty", "sessions", "remove", "delete", "scrub"],
|
keywords: () => splitKeywords("commands.scrubSessions.keywords"),
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
if (!instance) return
|
if (!instance) return
|
||||||
@@ -159,10 +167,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "switch-to-info",
|
id: "switch-to-info",
|
||||||
label: "Instance Info",
|
label: () => tGlobal("commands.instanceInfo.label"),
|
||||||
description: "Open the instance overview for logs and status",
|
description: () => tGlobal("commands.instanceInfo.description"),
|
||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: ["info", "logs", "console", "output"],
|
keywords: () => splitKeywords("commands.instanceInfo.keywords"),
|
||||||
shortcut: { key: "L", meta: true, shift: true },
|
shortcut: { key: "L", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -172,10 +180,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "session-next",
|
id: "session-next",
|
||||||
label: "Next Session",
|
label: () => tGlobal("commands.nextSession.label"),
|
||||||
description: "Cycle to next session tab",
|
description: () => tGlobal("commands.nextSession.description"),
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["switch", "navigate"],
|
keywords: () => splitKeywords("commands.nextSession.keywords"),
|
||||||
shortcut: { key: "]", meta: true, shift: true },
|
shortcut: { key: "]", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const instanceId = activeInstanceId()
|
const instanceId = activeInstanceId()
|
||||||
@@ -197,10 +205,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "session-prev",
|
id: "session-prev",
|
||||||
label: "Previous Session",
|
label: () => tGlobal("commands.previousSession.label"),
|
||||||
description: "Cycle to previous session tab",
|
description: () => tGlobal("commands.previousSession.description"),
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["switch", "navigate"],
|
keywords: () => splitKeywords("commands.previousSession.keywords"),
|
||||||
shortcut: { key: "[", meta: true, shift: true },
|
shortcut: { key: "[", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const instanceId = activeInstanceId()
|
const instanceId = activeInstanceId()
|
||||||
@@ -223,10 +231,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "compact",
|
id: "compact",
|
||||||
label: "Compact Session",
|
label: () => tGlobal("commands.compactSession.label"),
|
||||||
description: "Summarize and compact the current session",
|
description: () => tGlobal("commands.compactSession.description"),
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["/compact", "summarize", "compress"],
|
keywords: () => ["/compact", ...splitKeywords("commands.compactSession.keywords")],
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
@@ -247,9 +255,9 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to compact session", error)
|
log.error("Failed to compact session", error)
|
||||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
const message = error instanceof Error ? error.message : tGlobal("commands.compactSession.errorFallback")
|
||||||
showAlertDialog(`Compact failed: ${message}`, {
|
showAlertDialog(tGlobal("commands.compactSession.alert.message", { message }), {
|
||||||
title: "Compact failed",
|
title: tGlobal("commands.compactSession.alert.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -275,10 +283,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "undo",
|
id: "undo",
|
||||||
label: "Undo Last Message",
|
label: () => tGlobal("commands.undoLastMessage.label"),
|
||||||
description: "Revert the last message",
|
description: () => tGlobal("commands.undoLastMessage.description"),
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["/undo", "revert", "undo"],
|
keywords: () => ["/undo", ...splitKeywords("commands.undoLastMessage.keywords")],
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
@@ -320,8 +328,8 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!messageID) {
|
if (!messageID) {
|
||||||
showAlertDialog("Nothing to undo", {
|
showAlertDialog(tGlobal("commands.undoLastMessage.none.message"), {
|
||||||
title: "No actions to undo",
|
title: tGlobal("commands.undoLastMessage.none.title"),
|
||||||
variant: "info",
|
variant: "info",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -351,8 +359,8 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to revert message", error)
|
log.error("Failed to revert message", error)
|
||||||
showAlertDialog("Failed to revert message", {
|
showAlertDialog(tGlobal("commands.undoLastMessage.failed.message"), {
|
||||||
title: "Undo failed",
|
title: tGlobal("commands.undoLastMessage.failed.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -362,10 +370,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "open-model-selector",
|
id: "open-model-selector",
|
||||||
label: "Open Model Selector",
|
label: () => tGlobal("commands.openModelSelector.label"),
|
||||||
description: "Choose a different model",
|
description: () => tGlobal("commands.openModelSelector.description"),
|
||||||
category: "Agent & Model",
|
category: "Agent & Model",
|
||||||
keywords: ["model", "llm", "ai"],
|
keywords: () => splitKeywords("commands.openModelSelector.keywords"),
|
||||||
shortcut: { key: "M", meta: true, shift: true },
|
shortcut: { key: "M", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -376,10 +384,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "open-variant-selector",
|
id: "open-variant-selector",
|
||||||
label: "Select Model Variant",
|
label: () => tGlobal("commands.selectModelVariant.label"),
|
||||||
description: "Choose a thinking effort for the current model",
|
description: () => tGlobal("commands.selectModelVariant.description"),
|
||||||
category: "Agent & Model",
|
category: "Agent & Model",
|
||||||
keywords: ["variant", "thinking", "reasoning", "effort"],
|
keywords: () => splitKeywords("commands.selectModelVariant.keywords"),
|
||||||
shortcut: { key: "T", meta: true, shift: true },
|
shortcut: { key: "T", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -390,10 +398,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "open-agent-selector",
|
id: "open-agent-selector",
|
||||||
label: "Open Agent Selector",
|
label: () => tGlobal("commands.openAgentSelector.label"),
|
||||||
description: "Choose a different agent",
|
description: () => tGlobal("commands.openAgentSelector.description"),
|
||||||
category: "Agent & Model",
|
category: "Agent & Model",
|
||||||
keywords: ["agent", "mode"],
|
keywords: () => splitKeywords("commands.openAgentSelector.keywords"),
|
||||||
shortcut: { key: "A", meta: true, shift: true },
|
shortcut: { key: "A", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -404,10 +412,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "clear-input",
|
id: "clear-input",
|
||||||
label: "Clear Input",
|
label: () => tGlobal("commands.clearInput.label"),
|
||||||
description: "Clear the prompt textarea",
|
description: () => tGlobal("commands.clearInput.description"),
|
||||||
category: "Input & Focus",
|
category: "Input & Focus",
|
||||||
keywords: ["clear", "reset"],
|
keywords: () => splitKeywords("commands.clearInput.keywords"),
|
||||||
shortcut: { key: "K", meta: true },
|
shortcut: { key: "K", meta: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const textarea = findVisiblePromptTextarea()
|
const textarea = findVisiblePromptTextarea()
|
||||||
@@ -417,19 +425,19 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "thinking",
|
id: "thinking",
|
||||||
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
|
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
|
||||||
description: "Show/hide AI thinking process",
|
description: () => tGlobal("commands.thinkingBlocks.description"),
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"],
|
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
|
||||||
action: options.toggleShowThinkingBlocks,
|
action: options.toggleShowThinkingBlocks,
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "timeline-tools",
|
id: "timeline-tools",
|
||||||
label: () => `${options.preferences().showTimelineTools ? "Hide" : "Show"} Timeline Tool Calls`,
|
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
|
||||||
description: "Toggle tool call entries in the message timeline",
|
description: () => tGlobal("commands.timelineToolCalls.description"),
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: ["timeline", "tool", "toggle"],
|
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
|
||||||
action: options.toggleShowTimelineTools,
|
action: options.toggleShowTimelineTools,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -437,11 +445,12 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
id: "thinking-default-visibility",
|
id: "thinking-default-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
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",
|
category: "System",
|
||||||
keywords: ["/thinking", "thinking", "reasoning", "expand", "collapse", "default"],
|
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
|
||||||
action: () => {
|
action: () => {
|
||||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||||
@@ -451,19 +460,25 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "diff-view-split",
|
id: "diff-view-split",
|
||||||
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
|
label: () => {
|
||||||
description: "Display tool-call diffs side-by-side",
|
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
|
||||||
|
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.diffViewSplit.description"),
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: ["diff", "split", "view"],
|
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
|
||||||
action: () => options.setDiffViewMode("split"),
|
action: () => options.setDiffViewMode("split"),
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "diff-view-unified",
|
id: "diff-view-unified",
|
||||||
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`,
|
label: () => {
|
||||||
description: "Display tool-call diffs inline",
|
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
|
||||||
|
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.diffViewUnified.description"),
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: ["diff", "unified", "view"],
|
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
|
||||||
action: () => options.setDiffViewMode("unified"),
|
action: () => options.setDiffViewMode("unified"),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -471,11 +486,12 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
id: "tool-output-default-visibility",
|
id: "tool-output-default-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
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",
|
category: "System",
|
||||||
keywords: ["tool", "output", "expand", "collapse"],
|
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
|
||||||
action: () => {
|
action: () => {
|
||||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
const mode = options.preferences().toolOutputExpansion || "expanded"
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||||
@@ -487,11 +503,12 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
id: "diagnostics-default-visibility",
|
id: "diagnostics-default-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
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",
|
category: "System",
|
||||||
keywords: ["diagnostics", "expand", "collapse"],
|
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
|
||||||
action: () => {
|
action: () => {
|
||||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||||
@@ -503,11 +520,12 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
id: "token-usage-visibility",
|
id: "token-usage-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
const visible = options.preferences().showUsageMetrics ?? true
|
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",
|
category: "System",
|
||||||
keywords: ["token", "usage", "cost", "stats"],
|
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
|
||||||
action: options.toggleUsageMetrics,
|
action: options.toggleUsageMetrics,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -515,21 +533,21 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
id: "auto-cleanup-blank-sessions",
|
id: "auto-cleanup-blank-sessions",
|
||||||
label: () => {
|
label: () => {
|
||||||
const enabled = options.preferences().autoCleanupBlankSessions
|
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",
|
category: "System",
|
||||||
keywords: ["auto", "cleanup", "blank", "sessions", "toggle"],
|
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
|
||||||
action: options.toggleAutoCleanupBlankSessions,
|
action: options.toggleAutoCleanupBlankSessions,
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "help",
|
id: "help",
|
||||||
label: "Show Help",
|
label: () => tGlobal("commands.showHelp.label"),
|
||||||
|
description: () => tGlobal("commands.showHelp.description"),
|
||||||
description: "Display keyboard shortcuts and help",
|
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: ["/help", "shortcuts", "help"],
|
keywords: () => ["/help", ...splitKeywords("commands.showHelp.keywords")],
|
||||||
action: () => {
|
action: () => {
|
||||||
log.info("Show help modal (not implemented)")
|
log.info("Show help modal (not implemented)")
|
||||||
},
|
},
|
||||||
|
|||||||
148
packages/ui/src/lib/i18n/index.tsx
Normal file
148
packages/ui/src/lib/i18n/index.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { createContext, createEffect, createMemo, createSignal, onCleanup, onMount, useContext } from "solid-js"
|
||||||
|
import type { ParentComponent } from "solid-js"
|
||||||
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import { enMessages } from "./messages/en"
|
||||||
|
import { esMessages } from "./messages/es"
|
||||||
|
import { frMessages } from "./messages/fr"
|
||||||
|
import { ruMessages } from "./messages/ru"
|
||||||
|
import { jaMessages } from "./messages/ja"
|
||||||
|
import { zhHansMessages } from "./messages/zh-Hans"
|
||||||
|
|
||||||
|
type Messages = Record<string, string>
|
||||||
|
|
||||||
|
export type TranslateParams = Record<string, unknown>
|
||||||
|
|
||||||
|
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
|
||||||
|
|
||||||
|
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
|
||||||
|
|
||||||
|
const messagesByLocale: Record<Locale, Messages> = {
|
||||||
|
en: enMessages,
|
||||||
|
es: esMessages,
|
||||||
|
fr: frMessages,
|
||||||
|
ru: ruMessages,
|
||||||
|
ja: jaMessages,
|
||||||
|
"zh-Hans": zhHansMessages,
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocaleTag(value: string): string {
|
||||||
|
return value.trim().replace(/_/g, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSupportedLocale(value: string | undefined): Locale | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const normalized = normalizeLocaleTag(value)
|
||||||
|
const lower = normalized.toLowerCase()
|
||||||
|
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||||
|
const exact = supportedLower.get(lower)
|
||||||
|
if (exact) return exact
|
||||||
|
|
||||||
|
const parts = lower.split("-")
|
||||||
|
const base = parts[0]
|
||||||
|
if (!base) return null
|
||||||
|
|
||||||
|
if (base === "zh") {
|
||||||
|
const zhHans = supportedLower.get("zh-hans")
|
||||||
|
return zhHans ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMatch = supportedLower.get(base)
|
||||||
|
return baseMatch ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectNavigatorLocale(): Locale | null {
|
||||||
|
if (typeof navigator === "undefined") return null
|
||||||
|
|
||||||
|
const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||||
|
? navigator.languages
|
||||||
|
: navigator.language
|
||||||
|
? [navigator.language]
|
||||||
|
: []
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const match = matchSupportedLocale(candidate)
|
||||||
|
if (match) return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 translateFrom(messages: Messages, key: string, params?: TranslateParams): string {
|
||||||
|
const current = messages[key]
|
||||||
|
const fallback = enMessages[key as keyof typeof enMessages]
|
||||||
|
const template = current ?? fallback ?? key
|
||||||
|
return interpolate(template, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [globalRevision, setGlobalRevision] = createSignal(0)
|
||||||
|
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
|
||||||
|
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
|
||||||
|
|
||||||
|
export function tGlobal(key: string, params?: TranslateParams): string {
|
||||||
|
globalRevision()
|
||||||
|
return translateFrom(globalMessages, key, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface I18nContextValue {
|
||||||
|
locale: () => Locale
|
||||||
|
t: (key: string, params?: TranslateParams) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue>()
|
||||||
|
|
||||||
|
export const I18nProvider: ParentComponent = (props) => {
|
||||||
|
const { preferences } = useConfig()
|
||||||
|
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
|
||||||
|
|
||||||
|
const previousMessages = globalMessages
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const detected = detectNavigatorLocale()
|
||||||
|
if (detected) setDetectedLocale(detected)
|
||||||
|
})
|
||||||
|
|
||||||
|
const locale = createMemo<Locale>(() => {
|
||||||
|
const configured = matchSupportedLocale(preferences().locale)
|
||||||
|
return configured ?? detectedLocale() ?? "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
|
||||||
|
|
||||||
|
function t(key: string, params?: TranslateParams): string {
|
||||||
|
return translateFrom(messages(), key, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
globalMessages = messages()
|
||||||
|
setGlobalRevision((value) => value + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
globalMessages = previousMessages
|
||||||
|
setGlobalRevision((value) => value + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const value: I18nContextValue = {
|
||||||
|
locale,
|
||||||
|
t,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const context = useContext(I18nContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useI18n must be used within I18nProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
6
packages/ui/src/lib/i18n/messages/en/advancedSettings.ts
Normal file
6
packages/ui/src/lib/i18n/messages/en/advancedSettings.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const advancedSettingsMessages = {
|
||||||
|
"advancedSettings.title": "Advanced Settings",
|
||||||
|
"advancedSettings.environmentVariables.title": "Environment Variables",
|
||||||
|
"advancedSettings.environmentVariables.subtitle": "Applied whenever a new OpenCode instance starts",
|
||||||
|
"advancedSettings.actions.close": "Close",
|
||||||
|
} as const
|
||||||
32
packages/ui/src/lib/i18n/messages/en/app.ts
Normal file
32
packages/ui/src/lib/i18n/messages/en/app.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const appMessages = {
|
||||||
|
"app.launchError.title": "Unable to launch OpenCode",
|
||||||
|
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.",
|
||||||
|
"app.launchError.binaryPathLabel": "Binary path",
|
||||||
|
"app.launchError.errorOutputLabel": "Error output",
|
||||||
|
"app.launchError.openAdvancedSettings": "Open Advanced Settings",
|
||||||
|
"app.launchError.close": "Close",
|
||||||
|
"app.launchError.closeTitle": "Close (Esc)",
|
||||||
|
"app.launchError.fallbackMessage": "Failed to launch workspace",
|
||||||
|
|
||||||
|
"app.stopInstance.confirmMessage": "Stop OpenCode instance? This will stop the server.",
|
||||||
|
"app.stopInstance.title": "Stop instance",
|
||||||
|
"app.stopInstance.confirmLabel": "Stop",
|
||||||
|
"app.stopInstance.cancelLabel": "Keep running",
|
||||||
|
|
||||||
|
"emptyState.logoAlt": "CodeNomad logo",
|
||||||
|
"emptyState.brandTitle": "CodeNomad",
|
||||||
|
"emptyState.tagline": "Select a folder to start coding with AI",
|
||||||
|
"emptyState.actions.selectFolder": "Select Folder",
|
||||||
|
"emptyState.actions.selecting": "Selecting...",
|
||||||
|
"emptyState.keyboardShortcut": "Keyboard shortcut: {shortcut}",
|
||||||
|
"emptyState.examples": "Examples: {example}",
|
||||||
|
"emptyState.multipleInstances": "You can have multiple instances of the same folder",
|
||||||
|
|
||||||
|
"releases.upgradeRequired.title": "Upgrade required",
|
||||||
|
"releases.upgradeRequired.message.withVersion": "Update to CodeNomad {version} to use the latest UI.",
|
||||||
|
"releases.upgradeRequired.message.noVersion": "Update CodeNomad to use the latest UI.",
|
||||||
|
"releases.upgradeRequired.action.getUpdate": "Get update",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI updated",
|
||||||
|
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
||||||
|
} as const
|
||||||
160
packages/ui/src/lib/i18n/messages/en/commands.ts
Normal file
160
packages/ui/src/lib/i18n/messages/en/commands.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
export const commandMessages = {
|
||||||
|
"commandPalette.title": "Command Palette",
|
||||||
|
"commandPalette.description": "Search and execute commands",
|
||||||
|
"commandPalette.searchPlaceholder": "Type a command or search...",
|
||||||
|
"commandPalette.empty": "No commands found for \"{query}\"",
|
||||||
|
"commandPalette.category.customCommands": "Custom Commands",
|
||||||
|
"commandPalette.category.instance": "Instance",
|
||||||
|
"commandPalette.category.session": "Session",
|
||||||
|
"commandPalette.category.agentModel": "Agent & Model",
|
||||||
|
"commandPalette.category.inputFocus": "Input & Focus",
|
||||||
|
"commandPalette.category.system": "System",
|
||||||
|
"commandPalette.category.other": "Other",
|
||||||
|
|
||||||
|
"commands.newInstance.label": "New Instance",
|
||||||
|
"commands.newInstance.description": "Open folder picker to create new instance",
|
||||||
|
"commands.newInstance.keywords": "folder, project, workspace",
|
||||||
|
|
||||||
|
"commands.closeInstance.label": "Close Instance",
|
||||||
|
"commands.closeInstance.description": "Stop current instance's server",
|
||||||
|
"commands.closeInstance.keywords": "stop, quit, close",
|
||||||
|
|
||||||
|
"commands.nextInstance.label": "Next Instance",
|
||||||
|
"commands.nextInstance.description": "Cycle to next instance tab",
|
||||||
|
"commands.nextInstance.keywords": "switch, navigate",
|
||||||
|
|
||||||
|
"commands.previousInstance.label": "Previous Instance",
|
||||||
|
"commands.previousInstance.description": "Cycle to previous instance tab",
|
||||||
|
"commands.previousInstance.keywords": "switch, navigate",
|
||||||
|
|
||||||
|
"commands.newSession.label": "New Session",
|
||||||
|
"commands.newSession.description": "Create a new parent session",
|
||||||
|
"commands.newSession.keywords": "create, start",
|
||||||
|
|
||||||
|
"commands.closeSession.label": "Close Session",
|
||||||
|
"commands.closeSession.description": "Close current parent session",
|
||||||
|
"commands.closeSession.keywords": "close, stop",
|
||||||
|
|
||||||
|
"commands.scrubSessions.label": "Scrub Sessions",
|
||||||
|
"commands.scrubSessions.description": "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
|
||||||
|
"commands.scrubSessions.keywords": "cleanup, blank, empty, sessions, remove, delete, scrub",
|
||||||
|
|
||||||
|
"commands.instanceInfo.label": "Instance Info",
|
||||||
|
"commands.instanceInfo.description": "Open the instance overview for logs and status",
|
||||||
|
"commands.instanceInfo.keywords": "info, logs, console, output",
|
||||||
|
|
||||||
|
"commands.nextSession.label": "Next Session",
|
||||||
|
"commands.nextSession.description": "Cycle to next session tab",
|
||||||
|
"commands.nextSession.keywords": "switch, navigate",
|
||||||
|
|
||||||
|
"commands.previousSession.label": "Previous Session",
|
||||||
|
"commands.previousSession.description": "Cycle to previous session tab",
|
||||||
|
"commands.previousSession.keywords": "switch, navigate",
|
||||||
|
|
||||||
|
"commands.compactSession.label": "Compact Session",
|
||||||
|
"commands.compactSession.description": "Summarize and compact the current session",
|
||||||
|
"commands.compactSession.keywords": "summarize, compress",
|
||||||
|
"commands.compactSession.errorFallback": "Failed to compact session",
|
||||||
|
"commands.compactSession.alert.title": "Compact failed",
|
||||||
|
"commands.compactSession.alert.message": "Compact failed: {message}",
|
||||||
|
|
||||||
|
"commands.undoLastMessage.label": "Undo Last Message",
|
||||||
|
"commands.undoLastMessage.description": "Revert the last message",
|
||||||
|
"commands.undoLastMessage.keywords": "revert, undo",
|
||||||
|
"commands.undoLastMessage.none.title": "No actions to undo",
|
||||||
|
"commands.undoLastMessage.none.message": "Nothing to undo",
|
||||||
|
"commands.undoLastMessage.failed.title": "Undo failed",
|
||||||
|
"commands.undoLastMessage.failed.message": "Failed to revert message",
|
||||||
|
|
||||||
|
"commands.openModelSelector.label": "Open Model Selector",
|
||||||
|
"commands.openModelSelector.description": "Choose a different model",
|
||||||
|
"commands.openModelSelector.keywords": "model, llm, ai",
|
||||||
|
|
||||||
|
"commands.selectModelVariant.label": "Select Model Variant",
|
||||||
|
"commands.selectModelVariant.description": "Choose a thinking effort for the current model",
|
||||||
|
"commands.selectModelVariant.keywords": "variant, thinking, reasoning, effort",
|
||||||
|
|
||||||
|
"commands.openAgentSelector.label": "Open Agent Selector",
|
||||||
|
"commands.openAgentSelector.description": "Choose a different agent",
|
||||||
|
"commands.openAgentSelector.keywords": "agent, mode",
|
||||||
|
|
||||||
|
"commands.clearInput.label": "Clear Input",
|
||||||
|
"commands.clearInput.description": "Clear the prompt textarea",
|
||||||
|
"commands.clearInput.keywords": "clear, reset",
|
||||||
|
|
||||||
|
"commands.thinkingBlocks.label.show": "Show Thinking Blocks",
|
||||||
|
"commands.thinkingBlocks.label.hide": "Hide Thinking Blocks",
|
||||||
|
"commands.thinkingBlocks.description": "Show/hide AI thinking process",
|
||||||
|
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide",
|
||||||
|
|
||||||
|
"commands.timelineToolCalls.label.show": "Show Timeline Tool Calls",
|
||||||
|
"commands.timelineToolCalls.label.hide": "Hide Timeline Tool Calls",
|
||||||
|
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
||||||
|
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
|
||||||
|
|
||||||
|
"commands.common.expanded": "Expanded",
|
||||||
|
"commands.common.collapsed": "Collapsed",
|
||||||
|
"commands.common.visible": "Visible",
|
||||||
|
"commands.common.hidden": "Hidden",
|
||||||
|
"commands.common.enabled": "Enabled",
|
||||||
|
"commands.common.disabled": "Disabled",
|
||||||
|
|
||||||
|
"commands.thinkingBlocksDefault.label": "Thinking Blocks Default · {state}",
|
||||||
|
"commands.thinkingBlocksDefault.description": "Toggle whether thinking blocks start expanded",
|
||||||
|
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default",
|
||||||
|
|
||||||
|
"commands.diffViewSplit.label": "Use Split Diff View",
|
||||||
|
"commands.diffViewSplit.description": "Display tool-call diffs side-by-side",
|
||||||
|
"commands.diffViewSplit.keywords": "diff, split, view",
|
||||||
|
|
||||||
|
"commands.diffViewUnified.label": "Use Unified Diff View",
|
||||||
|
"commands.diffViewUnified.description": "Display tool-call diffs inline",
|
||||||
|
"commands.diffViewUnified.keywords": "diff, unified, view",
|
||||||
|
|
||||||
|
"commands.toolOutputsDefault.label": "Tool Outputs Default · {state}",
|
||||||
|
"commands.toolOutputsDefault.description": "Toggle default expansion for tool outputs",
|
||||||
|
"commands.toolOutputsDefault.keywords": "tool, output, expand, collapse",
|
||||||
|
|
||||||
|
"commands.diagnosticsDefault.label": "Diagnostics Default · {state}",
|
||||||
|
"commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output",
|
||||||
|
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse",
|
||||||
|
|
||||||
|
"commands.tokenUsageDisplay.label": "Token Usage Display · {state}",
|
||||||
|
"commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages",
|
||||||
|
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats",
|
||||||
|
|
||||||
|
"commands.autoCleanupBlankSessions.label": "Auto-Cleanup Blank Sessions · {state}",
|
||||||
|
"commands.autoCleanupBlankSessions.description": "Automatically clean up blank sessions when creating new ones",
|
||||||
|
"commands.autoCleanupBlankSessions.keywords": "auto, cleanup, blank, sessions, toggle",
|
||||||
|
|
||||||
|
"commands.showHelp.label": "Show Help",
|
||||||
|
"commands.showHelp.description": "Display keyboard shortcuts and help",
|
||||||
|
"commands.showHelp.keywords": "shortcuts, help",
|
||||||
|
|
||||||
|
"commands.custom.argumentsPrompt.message": "Arguments for /{name}",
|
||||||
|
"commands.custom.argumentsPrompt.title": "Custom command",
|
||||||
|
"commands.custom.argumentsPrompt.inputLabel": "Arguments",
|
||||||
|
"commands.custom.argumentsPrompt.inputPlaceholder": "e.g. foo bar",
|
||||||
|
"commands.custom.argumentsPrompt.confirmLabel": "Run",
|
||||||
|
"commands.custom.argumentsPrompt.cancelLabel": "Cancel",
|
||||||
|
"commands.custom.argumentsPrompt.openFailed.message": "Failed to open arguments prompt.",
|
||||||
|
"commands.custom.argumentsPrompt.openFailed.title": "Command arguments",
|
||||||
|
"commands.custom.entries.descriptionFallback": "Custom command",
|
||||||
|
"commands.custom.sessionRequired.message": "Select a session before running a custom command.",
|
||||||
|
"commands.custom.sessionRequired.title": "Session required",
|
||||||
|
"commands.custom.runFailed.message": "Failed to run custom command. Check the console for details.",
|
||||||
|
"commands.custom.runFailed.title": "Command failed",
|
||||||
|
|
||||||
|
"unifiedPicker.loading.searching": "Searching...",
|
||||||
|
"unifiedPicker.loading.loadingWorkspace": "Loading workspace...",
|
||||||
|
"unifiedPicker.title.command": "Select Command",
|
||||||
|
"unifiedPicker.title.mention": "Select Agent or File",
|
||||||
|
"unifiedPicker.empty": "No results found",
|
||||||
|
"unifiedPicker.sections.commands": "COMMANDS",
|
||||||
|
"unifiedPicker.sections.agents": "AGENTS",
|
||||||
|
"unifiedPicker.sections.files": "FILES",
|
||||||
|
"unifiedPicker.badge.subagent": "subagent",
|
||||||
|
"unifiedPicker.footer.navigate": "navigate",
|
||||||
|
"unifiedPicker.footer.select": "select",
|
||||||
|
"unifiedPicker.footer.close": "close",
|
||||||
|
} as const
|
||||||
16
packages/ui/src/lib/i18n/messages/en/dialogs.ts
Normal file
16
packages/ui/src/lib/i18n/messages/en/dialogs.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const dialogMessages = {
|
||||||
|
"alertDialog.fallbackTitle.info": "Heads up",
|
||||||
|
"alertDialog.fallbackTitle.warning": "Please review",
|
||||||
|
"alertDialog.fallbackTitle.error": "Something went wrong",
|
||||||
|
"alertDialog.actions.confirm": "Confirm",
|
||||||
|
"alertDialog.actions.run": "Run",
|
||||||
|
"alertDialog.actions.ok": "OK",
|
||||||
|
"alertDialog.actions.cancel": "Cancel",
|
||||||
|
"alertDialog.prompt.inputLabel": "Input",
|
||||||
|
|
||||||
|
"backgroundProcessOutputDialog.title": "Background Output",
|
||||||
|
"backgroundProcessOutputDialog.actions.close": "Close",
|
||||||
|
"backgroundProcessOutputDialog.loading": "Loading output...",
|
||||||
|
"backgroundProcessOutputDialog.truncatedNotice": "Output truncated for display.",
|
||||||
|
"backgroundProcessOutputDialog.loadErrorFallback": "Failed to load output.",
|
||||||
|
} as const
|
||||||
43
packages/ui/src/lib/i18n/messages/en/filesystem.ts
Normal file
43
packages/ui/src/lib/i18n/messages/en/filesystem.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export const filesystemMessages = {
|
||||||
|
"directoryBrowser.defaultDescription": "Browse folders under the configured workspace root.",
|
||||||
|
"directoryBrowser.close": "Close",
|
||||||
|
"directoryBrowser.currentFolder": "Current folder",
|
||||||
|
"directoryBrowser.selectCurrent": "Select Current",
|
||||||
|
"directoryBrowser.newFolder": "New Folder",
|
||||||
|
"directoryBrowser.creating": "Creating…",
|
||||||
|
"directoryBrowser.loadingFolders": "Loading folders…",
|
||||||
|
"directoryBrowser.noFolders": "No folders available.",
|
||||||
|
"directoryBrowser.upOneLevel": "Up one level",
|
||||||
|
"directoryBrowser.select": "Select",
|
||||||
|
"directoryBrowser.load.errorFallback": "Unable to load filesystem",
|
||||||
|
"directoryBrowser.createFolder.promptMessage": "Create a new folder in the current directory.",
|
||||||
|
"directoryBrowser.createFolder.title": "New Folder",
|
||||||
|
"directoryBrowser.createFolder.inputLabel": "Folder name",
|
||||||
|
"directoryBrowser.createFolder.inputPlaceholder": "e.g. my-new-project",
|
||||||
|
"directoryBrowser.createFolder.confirmLabel": "Create",
|
||||||
|
"directoryBrowser.createFolder.cancelLabel": "Cancel",
|
||||||
|
"directoryBrowser.createFolder.invalidNameMessage": "Please enter a single folder name.",
|
||||||
|
"directoryBrowser.createFolder.invalidNameDetail": "Folder names cannot include slashes, '..', or '~'.",
|
||||||
|
"directoryBrowser.createFolder.errorFallback": "Unable to create folder",
|
||||||
|
|
||||||
|
"filesystemBrowser.descriptionFallback": "Search for a path under the configured workspace root.",
|
||||||
|
"filesystemBrowser.rootLabel": "Root: {root}",
|
||||||
|
"filesystemBrowser.actions.close": "Close",
|
||||||
|
"filesystemBrowser.actions.retry": "Retry",
|
||||||
|
"filesystemBrowser.actions.select": "Select",
|
||||||
|
"filesystemBrowser.filterLabel": "Filter",
|
||||||
|
"filesystemBrowser.search.placeholder.directories": "Search for folders",
|
||||||
|
"filesystemBrowser.search.placeholder.files": "Search for files",
|
||||||
|
"filesystemBrowser.currentFolder.label": "Current folder",
|
||||||
|
"filesystemBrowser.currentFolder.selectCurrent": "Select Current",
|
||||||
|
"filesystemBrowser.loading.filesystem": "filesystem",
|
||||||
|
"filesystemBrowser.loading.workspaceRoot": "workspace root",
|
||||||
|
"filesystemBrowser.loading.loadingWithPath": "Loading {path}…",
|
||||||
|
"filesystemBrowser.empty.noEntries": "No entries found.",
|
||||||
|
"filesystemBrowser.navigation.upOneLevel": "Up one level",
|
||||||
|
"filesystemBrowser.hints.navigate": "Navigate",
|
||||||
|
"filesystemBrowser.hints.select": "Select",
|
||||||
|
"filesystemBrowser.hints.close": "Close",
|
||||||
|
"filesystemBrowser.errors.loadFilesystemFallback": "Unable to load filesystem",
|
||||||
|
"filesystemBrowser.errors.openDirectoryFallback": "Unable to open directory",
|
||||||
|
} as const
|
||||||
36
packages/ui/src/lib/i18n/messages/en/folderSelection.ts
Normal file
36
packages/ui/src/lib/i18n/messages/en/folderSelection.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export const folderSelectionMessages = {
|
||||||
|
"folderSelection.language.ariaLabel": "Language",
|
||||||
|
|
||||||
|
"folderSelection.logoAlt": "CodeNomad logo",
|
||||||
|
"folderSelection.tagline": "Select a folder to start coding with AI",
|
||||||
|
|
||||||
|
"folderSelection.links.github": "CodeNomad GitHub",
|
||||||
|
"folderSelection.links.githubStars": "CodeNomad GitHub Stars",
|
||||||
|
"folderSelection.links.discord": "CodeNomad Discord",
|
||||||
|
|
||||||
|
"folderSelection.empty.title": "No Recent Folders",
|
||||||
|
"folderSelection.empty.description": "Browse for a folder to get started",
|
||||||
|
|
||||||
|
"folderSelection.recent.title": "Recent Folders",
|
||||||
|
"folderSelection.recent.subtitle.one": "{count} folder available",
|
||||||
|
"folderSelection.recent.subtitle.other": "{count} folders available",
|
||||||
|
"folderSelection.recent.remove": "Remove from recent",
|
||||||
|
|
||||||
|
"folderSelection.browse.title": "Browse for Folder",
|
||||||
|
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
||||||
|
"folderSelection.browse.button": "Browse Folders",
|
||||||
|
"folderSelection.browse.buttonOpening": "Opening...",
|
||||||
|
|
||||||
|
"folderSelection.advancedSettings": "Advanced Settings",
|
||||||
|
|
||||||
|
"folderSelection.hints.navigate": "Navigate",
|
||||||
|
"folderSelection.hints.select": "Select",
|
||||||
|
"folderSelection.hints.remove": "Remove",
|
||||||
|
"folderSelection.hints.browse": "Browse",
|
||||||
|
|
||||||
|
"folderSelection.loading.title": "Starting instance...",
|
||||||
|
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
||||||
|
|
||||||
|
"folderSelection.dialog.title": "Select Workspace",
|
||||||
|
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||||
|
} as const
|
||||||
36
packages/ui/src/lib/i18n/messages/en/index.ts
Normal file
36
packages/ui/src/lib/i18n/messages/en/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { advancedSettingsMessages } from "./advancedSettings"
|
||||||
|
import { appMessages } from "./app"
|
||||||
|
import { commandMessages } from "./commands"
|
||||||
|
import { dialogMessages } from "./dialogs"
|
||||||
|
import { filesystemMessages } from "./filesystem"
|
||||||
|
import { folderSelectionMessages } from "./folderSelection"
|
||||||
|
import { instanceMessages } from "./instance"
|
||||||
|
import { loadingScreenMessages } from "./loadingScreen"
|
||||||
|
import { logMessages } from "./logs"
|
||||||
|
import { markdownMessages } from "./markdown"
|
||||||
|
import { messagingMessages } from "./messaging"
|
||||||
|
import { remoteAccessMessages } from "./remoteAccess"
|
||||||
|
import { sessionMessages } from "./session"
|
||||||
|
import { settingsMessages } from "./settings"
|
||||||
|
import { timeMessages } from "./time"
|
||||||
|
import { toolCallMessages } from "./toolCall"
|
||||||
|
import { mergeMessageParts } from "../merge"
|
||||||
|
|
||||||
|
export const enMessages = mergeMessageParts(
|
||||||
|
folderSelectionMessages,
|
||||||
|
advancedSettingsMessages,
|
||||||
|
loadingScreenMessages,
|
||||||
|
timeMessages,
|
||||||
|
appMessages,
|
||||||
|
dialogMessages,
|
||||||
|
filesystemMessages,
|
||||||
|
instanceMessages,
|
||||||
|
logMessages,
|
||||||
|
sessionMessages,
|
||||||
|
messagingMessages,
|
||||||
|
toolCallMessages,
|
||||||
|
markdownMessages,
|
||||||
|
settingsMessages,
|
||||||
|
remoteAccessMessages,
|
||||||
|
commandMessages,
|
||||||
|
)
|
||||||
125
packages/ui/src/lib/i18n/messages/en/instance.ts
Normal file
125
packages/ui/src/lib/i18n/messages/en/instance.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
export const instanceMessages = {
|
||||||
|
"instanceTabs.new.title": "New instance (Cmd/Ctrl+N)",
|
||||||
|
"instanceTabs.new.ariaLabel": "New instance",
|
||||||
|
"instanceTabs.remote.title": "Remote connect",
|
||||||
|
"instanceTabs.remote.ariaLabel": "Remote connect",
|
||||||
|
|
||||||
|
"instanceInfo.title": "Instance Information",
|
||||||
|
"instanceInfo.labels.folder": "Folder",
|
||||||
|
"instanceInfo.labels.project": "Project",
|
||||||
|
"instanceInfo.labels.versionControl": "Version Control",
|
||||||
|
"instanceInfo.labels.opencodeVersion": "OpenCode Version",
|
||||||
|
"instanceInfo.labels.binaryPath": "Binary Path",
|
||||||
|
"instanceInfo.labels.environmentVariables": "Environment Variables ({count})",
|
||||||
|
"instanceInfo.loading": "Loading...",
|
||||||
|
"instanceInfo.server.title": "Server",
|
||||||
|
"instanceInfo.server.port": "Port:",
|
||||||
|
"instanceInfo.server.pid": "PID:",
|
||||||
|
"instanceInfo.server.status": "Status:",
|
||||||
|
|
||||||
|
"instanceTab.status.permission": "Waiting on permission",
|
||||||
|
"instanceTab.status.compacting": "Compacting",
|
||||||
|
"instanceTab.status.working": "Working",
|
||||||
|
"instanceTab.status.idle": "Idle",
|
||||||
|
"instanceTab.status.ariaLabel": "Instance status: {status}",
|
||||||
|
"instanceTab.actions.close.ariaLabel": "Close instance",
|
||||||
|
|
||||||
|
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
||||||
|
"instanceShell.leftPanel.instanceInfo": "Instance Info",
|
||||||
|
|
||||||
|
"instanceShell.leftDrawer.pin": "Pin left drawer",
|
||||||
|
"instanceShell.leftDrawer.unpin": "Unpin left drawer",
|
||||||
|
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
|
||||||
|
"instanceShell.leftDrawer.toggle.open": "Open left drawer",
|
||||||
|
"instanceShell.leftDrawer.toggle.close": "Close left drawer",
|
||||||
|
|
||||||
|
"instanceShell.rightDrawer.pin": "Pin right drawer",
|
||||||
|
"instanceShell.rightDrawer.unpin": "Unpin right drawer",
|
||||||
|
"instanceShell.rightDrawer.toggle.pinned": "Right drawer pinned",
|
||||||
|
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
|
||||||
|
"instanceShell.rightDrawer.toggle.close": "Close right drawer",
|
||||||
|
|
||||||
|
"instanceShell.metrics.usedLabel": "Used",
|
||||||
|
"instanceShell.metrics.availableLabel": "Avail",
|
||||||
|
|
||||||
|
"instanceShell.commandPalette.openAriaLabel": "Open command palette",
|
||||||
|
"instanceShell.commandPalette.button": "Command Palette",
|
||||||
|
|
||||||
|
"instanceShell.connection.ariaLabel": "Connection {status}",
|
||||||
|
"instanceShell.connection.connected": "Connected",
|
||||||
|
"instanceShell.connection.connecting": "Connecting...",
|
||||||
|
"instanceShell.connection.disconnected": "Disconnected",
|
||||||
|
"instanceShell.connection.unknown": "Unknown",
|
||||||
|
|
||||||
|
"instanceWelcome.shortcuts.newSession": "New Session",
|
||||||
|
"instanceWelcome.empty.title": "No Previous Sessions",
|
||||||
|
"instanceWelcome.empty.description": "Create a new session below to get started",
|
||||||
|
"instanceWelcome.loading.title": "Loading Sessions",
|
||||||
|
"instanceWelcome.loading.description": "Fetching your previous sessions...",
|
||||||
|
"instanceWelcome.resume.title": "Resume Session",
|
||||||
|
"instanceWelcome.resume.subtitle.one": "{count} session available",
|
||||||
|
"instanceWelcome.resume.subtitle.other": "{count} sessions available",
|
||||||
|
"instanceWelcome.session.untitled": "Untitled Session",
|
||||||
|
"instanceWelcome.new.title": "Start New Session",
|
||||||
|
"instanceWelcome.new.subtitle": "We’ll reuse your last agent/model automatically",
|
||||||
|
"instanceWelcome.new.createButton": "Create Session",
|
||||||
|
"instanceWelcome.overlay.close": "Close",
|
||||||
|
"instanceWelcome.actions.viewInstanceInfo": "View Instance Info",
|
||||||
|
"instanceWelcome.actions.renameTitle": "Rename session",
|
||||||
|
"instanceWelcome.actions.deleteTitle": "Delete session",
|
||||||
|
"instanceWelcome.hints.navigate": "Navigate",
|
||||||
|
"instanceWelcome.hints.jump": "Jump",
|
||||||
|
"instanceWelcome.hints.firstLast": "First/Last",
|
||||||
|
"instanceWelcome.hints.resume": "Resume",
|
||||||
|
"instanceWelcome.hints.delete": "Delete",
|
||||||
|
"instanceWelcome.toasts.renameError": "Unable to rename session",
|
||||||
|
|
||||||
|
"instanceDisconnected.title": "Instance Disconnected",
|
||||||
|
"instanceDisconnected.folderFallback": "this workspace",
|
||||||
|
"instanceDisconnected.reasonFallback": "The server stopped responding",
|
||||||
|
"instanceDisconnected.description": "{folder} can no longer be reached. Close the tab to continue working.",
|
||||||
|
"instanceDisconnected.details.title": "Details",
|
||||||
|
"instanceDisconnected.details.folderLabel": "Folder:",
|
||||||
|
"instanceDisconnected.actions.closeInstance": "Close Instance",
|
||||||
|
|
||||||
|
"instanceShell.empty.title": "No session selected",
|
||||||
|
"instanceShell.empty.description": "Select a session to view messages",
|
||||||
|
|
||||||
|
"instanceShell.rightPanel.title": "Status Panel",
|
||||||
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
|
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
|
||||||
|
"instanceShell.rightPanel.sections.mcp": "MCP Servers",
|
||||||
|
"instanceShell.rightPanel.sections.lsp": "LSP Servers",
|
||||||
|
"instanceShell.rightPanel.sections.plugins": "Plugins",
|
||||||
|
|
||||||
|
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||||
|
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||||
|
|
||||||
|
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
||||||
|
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
||||||
|
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
||||||
|
"instanceShell.backgroundProcesses.actions.output": "Output",
|
||||||
|
"instanceShell.backgroundProcesses.actions.stop": "Stop",
|
||||||
|
"instanceShell.backgroundProcesses.actions.terminate": "Terminate",
|
||||||
|
|
||||||
|
"versionPill.appWithVersion": "App {version}",
|
||||||
|
"versionPill.ui": "UI",
|
||||||
|
"versionPill.uiWithVersion": "UI {version}",
|
||||||
|
"versionPill.source": " ({source})",
|
||||||
|
|
||||||
|
"opencodeBinarySelector.title": "OpenCode Binary",
|
||||||
|
"opencodeBinarySelector.subtitle": "Choose which executable OpenCode should run",
|
||||||
|
"opencodeBinarySelector.customPath.placeholder": "Enter path to opencode binary…",
|
||||||
|
"opencodeBinarySelector.actions.add": "Add",
|
||||||
|
"opencodeBinarySelector.actions.browse": "Browse for Binary…",
|
||||||
|
"opencodeBinarySelector.actions.removeTitle": "Remove binary",
|
||||||
|
"opencodeBinarySelector.badge.systemPath": "Use binary from system PATH",
|
||||||
|
"opencodeBinarySelector.status.checkingVersions": "Checking versions…",
|
||||||
|
"opencodeBinarySelector.status.checking": "Checking…",
|
||||||
|
"opencodeBinarySelector.dialog.title": "Select OpenCode Binary",
|
||||||
|
"opencodeBinarySelector.dialog.description": "Browse files exposed by the CLI server.",
|
||||||
|
"opencodeBinarySelector.validation.invalidBinary": "Invalid OpenCode binary",
|
||||||
|
"opencodeBinarySelector.validation.alreadyValidating": "Already validating",
|
||||||
|
"opencodeBinarySelector.display.systemPath": "{name} (system PATH)",
|
||||||
|
"opencodeBinarySelector.versionLabel": "v{version}",
|
||||||
|
} as const
|
||||||
17
packages/ui/src/lib/i18n/messages/en/loadingScreen.ts
Normal file
17
packages/ui/src/lib/i18n/messages/en/loadingScreen.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const loadingScreenMessages = {
|
||||||
|
"loadingScreen.logoAlt": "CodeNomad logo",
|
||||||
|
"loadingScreen.status.issue": "Encountered an issue",
|
||||||
|
"loadingScreen.actions.showAnother": "Show another",
|
||||||
|
"loadingScreen.errors.missingRoot": "Loading root element not found",
|
||||||
|
|
||||||
|
"loadingScreen.phrases.neurons": "Warming up the AI neurons…",
|
||||||
|
"loadingScreen.phrases.daydreaming": "Convincing the AI to stop daydreaming…",
|
||||||
|
"loadingScreen.phrases.goggles": "Polishing the AI’s code goggles…",
|
||||||
|
"loadingScreen.phrases.reorganizingFiles": "Asking the AI to stop reorganizing your files…",
|
||||||
|
"loadingScreen.phrases.coffee": "Feeding the AI additional coffee…",
|
||||||
|
"loadingScreen.phrases.nodeModules": "Teaching the AI not to delete node_modules (again)…",
|
||||||
|
"loadingScreen.phrases.actNatural": "Telling the AI to act natural before you arrive…",
|
||||||
|
"loadingScreen.phrases.rewritingHistory": "Asking the AI to please stop rewriting history…",
|
||||||
|
"loadingScreen.phrases.stretch": "Letting the AI stretch before its coding sprint…",
|
||||||
|
"loadingScreen.phrases.keyboardControl": "Persuading the AI to give you keyboard control…",
|
||||||
|
} as const
|
||||||
18
packages/ui/src/lib/i18n/messages/en/logs.ts
Normal file
18
packages/ui/src/lib/i18n/messages/en/logs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const logMessages = {
|
||||||
|
"logsView.title": "Server Logs",
|
||||||
|
"logsView.actions.show": "Show server logs",
|
||||||
|
"logsView.actions.hide": "Hide server logs",
|
||||||
|
"logsView.envVars.title": "Environment Variables ({count})",
|
||||||
|
"logsView.paused.title": "Server logs are paused",
|
||||||
|
"logsView.paused.description": "Enable streaming to watch your OpenCode server activity.",
|
||||||
|
"logsView.empty.waiting": "Waiting for server output...",
|
||||||
|
"logsView.scrollToBottom": "Scroll to bottom",
|
||||||
|
|
||||||
|
"infoView.logs.title": "Server Logs",
|
||||||
|
"infoView.logs.actions.show": "Show server logs",
|
||||||
|
"infoView.logs.actions.hide": "Hide server logs",
|
||||||
|
"infoView.logs.paused.title": "Server logs are paused",
|
||||||
|
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
|
||||||
|
"infoView.logs.empty.waiting": "Waiting for server output...",
|
||||||
|
"infoView.logs.scrollToBottom": "Scroll to bottom",
|
||||||
|
} as const
|
||||||
7
packages/ui/src/lib/i18n/messages/en/markdown.ts
Normal file
7
packages/ui/src/lib/i18n/messages/en/markdown.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const markdownMessages = {
|
||||||
|
"markdown.codeBlock.copy.label": "Copy",
|
||||||
|
"markdown.codeBlock.copy.copied": "Copied!",
|
||||||
|
"markdown.codeBlock.copy.failed": "Failed",
|
||||||
|
|
||||||
|
"markdown.copy": "Copy",
|
||||||
|
} as const
|
||||||
109
packages/ui/src/lib/i18n/messages/en/messaging.ts
Normal file
109
packages/ui/src/lib/i18n/messages/en/messaging.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
export const messagingMessages = {
|
||||||
|
"messageListHeader.sidebar.openSessionListAriaLabel": "Open session list",
|
||||||
|
"messageListHeader.metrics.usedLabel": "Used",
|
||||||
|
"messageListHeader.metrics.availableLabel": "Avail",
|
||||||
|
"messageListHeader.commandPalette.ariaLabel": "Open command palette",
|
||||||
|
"messageListHeader.commandPalette.button": "Command Palette",
|
||||||
|
"messageListHeader.connection.connected": "Connected",
|
||||||
|
"messageListHeader.connection.connecting": "Connecting...",
|
||||||
|
"messageListHeader.connection.disconnected": "Disconnected",
|
||||||
|
|
||||||
|
"messageSection.empty.logoAlt": "CodeNomad logo",
|
||||||
|
"messageSection.empty.brandTitle": "CodeNomad",
|
||||||
|
"messageSection.empty.title": "Start a conversation",
|
||||||
|
"messageSection.empty.description": "Type a message below or open the Command Palette:",
|
||||||
|
"messageSection.empty.tips.commandPalette": "Command Palette",
|
||||||
|
"messageSection.empty.tips.askAboutCodebase": "Ask about your codebase",
|
||||||
|
"messageSection.empty.tips.attachFilesPrefix": "Attach files with",
|
||||||
|
"messageSection.loading.messages": "Loading messages...",
|
||||||
|
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
|
||||||
|
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||||
|
"messageSection.quote.addAsQuote": "Add as quote",
|
||||||
|
"messageSection.quote.addAsCode": "Add as code",
|
||||||
|
|
||||||
|
"messageTimeline.ariaLabel": "Message timeline",
|
||||||
|
"messageTimeline.segment.user.label": "You",
|
||||||
|
"messageTimeline.segment.assistant.label": "Asst",
|
||||||
|
"messageTimeline.segment.compaction.label": "Compaction",
|
||||||
|
"messageTimeline.tool.fallbackLabel": "Tool Call",
|
||||||
|
"messageTimeline.tooltip.userFallback": "User message",
|
||||||
|
"messageTimeline.tooltip.assistantFallback": "Assistant response",
|
||||||
|
"messageTimeline.tooltip.compaction.auto": "Auto Compaction",
|
||||||
|
"messageTimeline.tooltip.compaction.manual": "User Compaction",
|
||||||
|
"messageTimeline.text.filePrefix": "[File] {filename}",
|
||||||
|
"messageTimeline.text.attachment": "Attachment",
|
||||||
|
|
||||||
|
"messageBlock.tool.header": "Tool Call",
|
||||||
|
"messageBlock.tool.unknown": "unknown",
|
||||||
|
"messageBlock.tool.goToSession.label": "Go to Session",
|
||||||
|
"messageBlock.tool.goToSession.title": "Go to session",
|
||||||
|
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
|
||||||
|
|
||||||
|
"messageBlock.compaction.ariaLabel": "Session compaction",
|
||||||
|
"messageBlock.compaction.autoLabel": "Session auto-compacted",
|
||||||
|
"messageBlock.compaction.manualLabel": "Session compacted by you",
|
||||||
|
"messageBlock.usage.input": "Input",
|
||||||
|
"messageBlock.usage.output": "Output",
|
||||||
|
"messageBlock.usage.reasoning": "Reasoning",
|
||||||
|
"messageBlock.usage.cacheRead": "Cache Read",
|
||||||
|
"messageBlock.usage.cacheWrite": "Cache Write",
|
||||||
|
"messageBlock.usage.cost": "Cost",
|
||||||
|
"messageBlock.step.agentLabel": "Agent: {agent}",
|
||||||
|
"messageBlock.step.modelLabel": "Model: {model}",
|
||||||
|
"messageBlock.reasoning.thinkingLabel": "Thinking",
|
||||||
|
"messageBlock.reasoning.expandAriaLabel": "Expand thinking",
|
||||||
|
"messageBlock.reasoning.collapseAriaLabel": "Collapse thinking",
|
||||||
|
"messageBlock.reasoning.indicator.hide": "Hide",
|
||||||
|
"messageBlock.reasoning.indicator.view": "View",
|
||||||
|
"messageBlock.reasoning.detailsAriaLabel": "Reasoning details",
|
||||||
|
|
||||||
|
"codeBlockInline.actions.copy": "Copy",
|
||||||
|
"codeBlockInline.actions.copied": "Copied!",
|
||||||
|
|
||||||
|
"messageItem.speaker.you": "You",
|
||||||
|
"messageItem.speaker.assistant": "Assistant",
|
||||||
|
"messageItem.actions.revert": "Revert",
|
||||||
|
"messageItem.actions.revertTitle": "Revert to this message",
|
||||||
|
"messageItem.actions.fork": "Fork",
|
||||||
|
"messageItem.actions.forkTitle": "Fork from this message",
|
||||||
|
"messageItem.actions.copy": "Copy",
|
||||||
|
"messageItem.actions.copyTitle": "Copy message",
|
||||||
|
"messageItem.actions.copied": "Copied!",
|
||||||
|
"messageItem.status.queued": "QUEUED",
|
||||||
|
"messageItem.status.generating": "Generating...",
|
||||||
|
"messageItem.status.sending": "Sending...",
|
||||||
|
"messageItem.status.failedToSend": "Message failed to send",
|
||||||
|
"messageItem.attachment.defaultName": "attachment",
|
||||||
|
"messageItem.attachment.downloadAriaLabel": "Download {name}",
|
||||||
|
"messageItem.agentMeta.agentLabel": "Agent: {agent}",
|
||||||
|
"messageItem.agentMeta.modelLabel": "Model: {model}",
|
||||||
|
"messageItem.errors.authenticationFallback": "Authentication error",
|
||||||
|
"messageItem.errors.outputLengthExceeded": "Message output length exceeded",
|
||||||
|
"messageItem.errors.requestAborted": "Request was aborted",
|
||||||
|
"messageItem.errors.unknownFallback": "Unknown error occurred",
|
||||||
|
|
||||||
|
"attachmentChip.removeAriaLabel": "Remove attachment",
|
||||||
|
|
||||||
|
"expandButton.toggleAriaLabel": "Toggle chat input height",
|
||||||
|
|
||||||
|
"promptInput.placeholder.shell": "Run a shell command (Esc to exit)...",
|
||||||
|
"promptInput.placeholder.default": "Type your message, @file, @agent, or paste images and text...",
|
||||||
|
"promptInput.hints.shell.exit": "to exit shell mode",
|
||||||
|
"promptInput.hints.shell.enable": "Shell mode",
|
||||||
|
"promptInput.hints.commands": "Commands",
|
||||||
|
"promptInput.history.previousAriaLabel": "Previous prompt",
|
||||||
|
"promptInput.history.nextAriaLabel": "Next prompt",
|
||||||
|
"promptInput.overlay.newLine": "New line",
|
||||||
|
"promptInput.overlay.send": "Send",
|
||||||
|
"promptInput.overlay.filesAgents": "Files/agents",
|
||||||
|
"promptInput.overlay.history": "History",
|
||||||
|
"promptInput.overlay.attachments": "• {count} file(s) attached",
|
||||||
|
"promptInput.overlay.shellModeActive": "Shell mode active",
|
||||||
|
"promptInput.overlay.press": "Press",
|
||||||
|
"promptInput.overlay.againToAbort": "again to abort session",
|
||||||
|
"promptInput.stopSession.ariaLabel": "Stop session",
|
||||||
|
"promptInput.stopSession.title": "Stop session",
|
||||||
|
"promptInput.send.ariaLabel": "Send message",
|
||||||
|
"promptInput.send.errorFallback": "Failed to send message",
|
||||||
|
"promptInput.send.errorTitle": "Send failed",
|
||||||
|
} as const
|
||||||
51
packages/ui/src/lib/i18n/messages/en/remoteAccess.ts
Normal file
51
packages/ui/src/lib/i18n/messages/en/remoteAccess.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export const remoteAccessMessages = {
|
||||||
|
"remoteAccess.eyebrow": "Remote handover",
|
||||||
|
"remoteAccess.title": "Connect to CodeNomad remotely",
|
||||||
|
"remoteAccess.subtitle": "Use the addresses below to open CodeNomad from another device.",
|
||||||
|
"remoteAccess.close": "Close remote access",
|
||||||
|
"remoteAccess.refresh": "Refresh",
|
||||||
|
|
||||||
|
"remoteAccess.sections.listeningMode.label": "Listening mode",
|
||||||
|
"remoteAccess.sections.listeningMode.help": "Allow or limit remote handovers by binding to all interfaces or just localhost.",
|
||||||
|
"remoteAccess.toggle.on": "On",
|
||||||
|
"remoteAccess.toggle.off": "Off",
|
||||||
|
"remoteAccess.toggle.title": "Allow connections from other IPs",
|
||||||
|
"remoteAccess.toggle.caption.all": "Binding to 0.0.0.0",
|
||||||
|
"remoteAccess.toggle.caption.local": "Binding to 127.0.0.1",
|
||||||
|
"remoteAccess.toggle.note": "Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the server restarts.",
|
||||||
|
"remoteAccess.listeningMode.restartConfirm.message": "Restart to apply listening mode? This will stop all running instances.",
|
||||||
|
"remoteAccess.listeningMode.restartConfirm.title.all": "Open to other devices",
|
||||||
|
"remoteAccess.listeningMode.restartConfirm.title.local": "Limit to this device",
|
||||||
|
"remoteAccess.listeningMode.restartConfirm.confirmLabel": "Restart now",
|
||||||
|
"remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancel",
|
||||||
|
"remoteAccess.restart.errorManual": "Unable to restart automatically. Please restart the app to apply the change.",
|
||||||
|
|
||||||
|
"remoteAccess.sections.serverPassword.label": "Server password",
|
||||||
|
"remoteAccess.sections.serverPassword.help": "Remote handovers require a password. Set a memorable one to enable logins from other devices.",
|
||||||
|
"remoteAccess.authStatus.unavailable": "Authentication status unavailable.",
|
||||||
|
"remoteAccess.username": "Username: {username}",
|
||||||
|
"remoteAccess.password.status.set": "A password is set for remote access.",
|
||||||
|
"remoteAccess.password.status.unset": "No memorable password is set yet. Set one to allow remote handover logins.",
|
||||||
|
"remoteAccess.password.actions.cancel": "Cancel",
|
||||||
|
"remoteAccess.password.actions.change": "Change password",
|
||||||
|
"remoteAccess.password.actions.set": "Set password",
|
||||||
|
"remoteAccess.password.form.newPassword": "New password",
|
||||||
|
"remoteAccess.password.form.confirmPassword": "Confirm password",
|
||||||
|
"remoteAccess.password.form.placeholder": "At least 8 characters",
|
||||||
|
"remoteAccess.password.error.tooShort": "Password must be at least 8 characters.",
|
||||||
|
"remoteAccess.password.error.mismatch": "Passwords do not match.",
|
||||||
|
"remoteAccess.password.save.saving": "Saving…",
|
||||||
|
"remoteAccess.password.save.label": "Save password",
|
||||||
|
|
||||||
|
"remoteAccess.sections.addresses.label": "Reachable addresses",
|
||||||
|
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
||||||
|
"remoteAccess.addresses.loading": "Loading addresses…",
|
||||||
|
"remoteAccess.addresses.none": "No addresses available yet.",
|
||||||
|
"remoteAccess.address.scope.network": "Network",
|
||||||
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
|
"remoteAccess.address.scope.internal": "Internal",
|
||||||
|
"remoteAccess.address.open": "Open",
|
||||||
|
"remoteAccess.address.showQr": "Show QR",
|
||||||
|
"remoteAccess.address.hideQr": "Hide QR",
|
||||||
|
"remoteAccess.address.qrAlt": "QR for {url}",
|
||||||
|
} as const
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user