Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f5f830a3 | ||
|
|
81102cc6bf | ||
|
|
afa7243eab | ||
|
|
37b7c1e53c | ||
|
|
ba61ab79e2 | ||
|
|
37d075fbb3 | ||
|
|
2961d41be3 | ||
|
|
1bb5aedfdb | ||
|
|
0a793fb1c6 | ||
|
|
a401eeec11 | ||
|
|
d9bcc66930 | ||
|
|
01921e3454 |
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.2",
|
"version": "0.9.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.2",
|
"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.2",
|
"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.2",
|
"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.2",
|
"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.2",
|
"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.2",
|
"version": "0.9.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.9.2",
|
"version": "0.9.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -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.2",
|
"version": "0.9.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.2",
|
"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.2",
|
"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) {
|
||||||
|
|||||||
@@ -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.2",
|
"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.2",
|
"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",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ChevronDown } from "lucide-solid"
|
|||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
import { useI18n } from "../lib/i18n"
|
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")
|
||||||
|
|
||||||
|
|
||||||
@@ -113,9 +112,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|||||||
@@ -88,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
|
||||||
@@ -936,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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -172,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 {
|
||||||
@@ -272,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
|
||||||
@@ -280,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
|
||||||
@@ -317,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)
|
||||||
@@ -427,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}
|
||||||
@@ -450,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>{t("messageBlock.tool.header")}</span>
|
|
||||||
<span class="tool-name">{toolItem.toolPart.tool || 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={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>
|
||||||
|
|||||||
@@ -137,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 = () => {
|
||||||
@@ -163,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +6,6 @@ import type { Model } from "../types/session"
|
|||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||||
import Kbd from "./kbd"
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -295,15 +294,12 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.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>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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"
|
||||||
@@ -217,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) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import Kbd from "./kbd"
|
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -93,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>
|
||||||
|
|||||||
@@ -235,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())
|
||||||
@@ -593,7 +597,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousPartVersion = version
|
previousPartVersion = version
|
||||||
scheduleAnchorScroll()
|
scheduleAnchorScroll(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export function createDiffContentRenderer(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 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
|
const toolbarLabel = options?.label || (relativePath
|
||||||
@@ -35,6 +43,8 @@ export function createDiffContentRenderer(params: {
|
|||||||
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 = (() => {
|
||||||
@@ -58,7 +68,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!options?.disableScrollTracking) {
|
if (!disableScrollTracking) {
|
||||||
params.handleScrollRendered()
|
params.handleScrollRendered()
|
||||||
}
|
}
|
||||||
params.onContentRendered?.()
|
params.onContentRendered?.()
|
||||||
@@ -67,8 +77,8 @@ 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={params.t("toolCall.diff.viewMode.ariaLabel")}>
|
<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>
|
||||||
@@ -100,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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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"
|
import { useI18n } from "../../lib/i18n"
|
||||||
@@ -28,6 +28,13 @@ export type QuestionToolBlockProps = {
|
|||||||
|
|
||||||
export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||||
const { t } = useI18n()
|
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()
|
||||||
@@ -206,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
|
||||||
@@ -214,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()}
|
||||||
@@ -234,6 +244,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
title={t("toolCall.question.custom.title")}
|
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()}
|
||||||
@@ -266,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>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
<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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import toast from "solid-toast"
|
import toast from "solid-toast"
|
||||||
|
import { isTauriHost } from "./runtime-env"
|
||||||
|
|
||||||
export type ToastVariant = "info" | "success" | "warning" | "error"
|
export type ToastVariant = "info" | "success" | "warning" | "error"
|
||||||
|
|
||||||
@@ -21,6 +22,31 @@ export type ToastPayload = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openExternalUrl(url: string): Promise<void> {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isTauriHost()) {
|
||||||
|
const { openUrl } = await import("@tauri-apps/plugin-opener")
|
||||||
|
await openUrl(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fall through to browser handling.
|
||||||
|
// Note: on Linux, system opener failures can throw here.
|
||||||
|
console.warn("[notifications] unable to open via system opener", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[notifications] unable to open external url", error)
|
||||||
|
toast.error("Unable to open link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const variantAccent: Record<
|
const variantAccent: Record<
|
||||||
ToastVariant,
|
ToastVariant,
|
||||||
{
|
{
|
||||||
@@ -80,14 +106,13 @@ export function showToastNotification(payload: ToastPayload): ToastHandle {
|
|||||||
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
|
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
|
||||||
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
|
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
|
||||||
{payload.action && (
|
{payload.action && (
|
||||||
<a
|
<button
|
||||||
|
type="button"
|
||||||
class="mt-3 inline-flex items-center text-xs font-semibold uppercase tracking-wide text-sky-300 hover:text-sky-200"
|
class="mt-3 inline-flex items-center text-xs font-semibold uppercase tracking-wide text-sky-300 hover:text-sky-200"
|
||||||
href={payload.action.href}
|
onClick={() => void openExternalUrl(payload.action!.href)}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
>
|
||||||
{payload.action.label}
|
{payload.action.label}
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -132,6 +132,13 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-sidebar-selector-hints {
|
||||||
|
@apply flex flex-wrap items-center w-full text-xs;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.session-header-hints {
|
.session-header-hints {
|
||||||
@apply flex-shrink-0;
|
@apply flex-shrink-0;
|
||||||
}
|
}
|
||||||
@@ -482,4 +489,3 @@
|
|||||||
border-color: var(--border-base);
|
border-color: var(--border-base);
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user