Compare commits
1 Commits
v0.9.3
...
fix/questi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abe96a7a8b |
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.3",
|
"version": "0.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.3",
|
"version": "0.9.2",
|
||||||
"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,16 +1419,6 @@
|
|||||||
"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,
|
||||||
@@ -1472,15 +1462,6 @@
|
|||||||
"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,
|
||||||
@@ -7403,7 +7384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.9.3",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -7437,7 +7418,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.3",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -7474,14 +7455,14 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.9.3",
|
"version": "0.9.2",
|
||||||
"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.3",
|
"version": "0.9.2",
|
||||||
"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",
|
||||||
@@ -7490,7 +7471,6 @@
|
|||||||
"@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.3",
|
"version": "0.9.2",
|
||||||
"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.3",
|
"version": "0.9.2",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -51,17 +51,8 @@ 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.3",
|
"version": "0.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.3",
|
"version": "0.9.2",
|
||||||
"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.3",
|
"version": "0.9.2",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -15,25 +15,15 @@ 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 | null
|
private readonly authStore: AuthStore
|
||||||
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" }))
|
||||||
|
|
||||||
@@ -47,10 +37,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -70,31 +56,19 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateLogin(username: string, password: string): boolean {
|
validateLogin(username: string, password: string): boolean {
|
||||||
if (!this.authEnabled) {
|
return this.authStore.validateCredentials(username, password)
|
||||||
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() {
|
||||||
if (!this.authEnabled) {
|
return this.authStore.getStatus()
|
||||||
return { username: this.init.username, passwordUserProvided: false }
|
|
||||||
}
|
|
||||||
return this.requireAuthStore().getStatus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPassword(password: string) {
|
setPassword(password: string) {
|
||||||
if (!this.authEnabled) {
|
return this.authStore.setPassword({ password, markUserProvided: true })
|
||||||
throw new Error("Internal authentication is disabled")
|
|
||||||
}
|
|
||||||
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoopbackRequest(request: FastifyRequest): boolean {
|
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||||
@@ -102,12 +76,6 @@ 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)
|
||||||
@@ -122,13 +90,6 @@ 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,7 +44,6 @@ interface CliOptions {
|
|||||||
authUsername: string
|
authUsername: string
|
||||||
authPassword?: string
|
authPassword?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT = 9898
|
const DEFAULT_PORT = 9898
|
||||||
@@ -85,14 +84,6 @@ 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<{
|
||||||
@@ -113,14 +104,8 @@ 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)
|
||||||
@@ -145,7 +130,6 @@ 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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +174,6 @@ 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.")
|
||||||
@@ -217,12 +195,11 @@ 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 && !options.dangerouslySkipAuth) {
|
if (options.generateToken) {
|
||||||
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,16 +380,6 @@ 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.3",
|
"version": "0.9.2",
|
||||||
"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:*", "http://tauri.localhost/*", "https://tauri.localhost/*"]
|
"urls": ["http://127.0.0.1:*", "http://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:*","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"]}}
|
{"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"]}}
|
||||||
@@ -464,33 +464,13 @@ 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 || {
|
||||||
// Do not hold the child mutex while waiting for process exit.
|
let code = {
|
||||||
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
|
let mut guard = child_holder.lock();
|
||||||
// same lock to send SIGTERM/SIGKILL when the user quits the app.
|
if let Some(child) = guard.as_mut() {
|
||||||
let code = loop {
|
child.wait().ok()
|
||||||
let maybe_exited = {
|
} else {
|
||||||
let mut guard = child_holder.lock();
|
None
|
||||||
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,7 +4,6 @@ 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;
|
||||||
@@ -12,8 +11,6 @@ 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,
|
||||||
@@ -42,10 +39,7 @@ 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,
|
||||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,11 +164,6 @@ 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 || {
|
||||||
@@ -189,9 +178,6 @@ 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.3",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"@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,6 +5,7 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +113,9 @@ 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 = 340
|
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
||||||
const MIN_SESSION_SIDEBAR_WIDTH = 220
|
const MIN_SESSION_SIDEBAR_WIDTH = 220
|
||||||
const MAX_SESSION_SIDEBAR_WIDTH = 400
|
const MAX_SESSION_SIDEBAR_WIDTH = 360
|
||||||
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,12 +936,6 @@ 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,212 +172,21 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
|||||||
interface ContentDisplayItem {
|
interface ContentDisplayItem {
|
||||||
type: "content"
|
type: "content"
|
||||||
key: string
|
key: string
|
||||||
messageId: string
|
record: MessageRecord
|
||||||
startPartId: string
|
parts: ClientPart[]
|
||||||
|
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
|
||||||
partId: string
|
messageVersion: number
|
||||||
}
|
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 {
|
||||||
@@ -463,6 +272,7 @@ 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
|
||||||
@@ -470,28 +280,34 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
|
|
||||||
const flushContent = () => {
|
const flushContent = () => {
|
||||||
if (pendingParts.length === 0) return
|
if (pendingParts.length === 0) return
|
||||||
const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : ""
|
const segmentKey = `${current.id}:segment:${segmentIndex}`
|
||||||
if (!startPartId) {
|
segmentIndex += 1
|
||||||
pendingParts = []
|
const shouldShowAgentMeta =
|
||||||
return
|
current.role === "assistant" &&
|
||||||
}
|
!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,
|
||||||
messageId: current.id,
|
record: current,
|
||||||
startPartId,
|
parts: pendingParts.slice(),
|
||||||
|
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
|
||||||
@@ -501,26 +317,28 @@ 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 partId = part.id
|
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
|
||||||
if (!partId) {
|
const messageVersion = current.revision
|
||||||
// Tool parts are required to have ids; if one slips through, skip rendering
|
const key = `${current.id}:${part.id ?? partIndex}`
|
||||||
// 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,
|
||||||
partId,
|
messageVersion,
|
||||||
|
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.partId = partId
|
toolItem.messageVersion = messageVersion
|
||||||
|
toolItem.partVersion = partVersion
|
||||||
}
|
}
|
||||||
items.push(toolItem)
|
items.push(toolItem)
|
||||||
blockToolKeys.push(key)
|
blockToolKeys.push(key)
|
||||||
@@ -609,21 +427,21 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={block()}>
|
<Show when={block()} keyed>
|
||||||
{(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"}>
|
||||||
<MessageContentItem
|
<MessageItem
|
||||||
|
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}
|
||||||
store={props.store}
|
isQueued={(item as ContentDisplayItem).isQueued}
|
||||||
messageId={(item as ContentDisplayItem).messageId}
|
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
||||||
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}
|
||||||
@@ -632,14 +450,46 @@ 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}>
|
||||||
<ToolCallItem
|
<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">{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,17 +137,8 @@ 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 Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
|
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
@@ -172,7 +163,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
if (!isUser() && !hasContent()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ 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
|
||||||
|
|
||||||
@@ -101,7 +94,7 @@ interface MessagePartProps {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={partType() === "text"}>
|
<Match when={partType() === "text"}>
|
||||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
|
||||||
<div class={textContainerClass()}>
|
<div class={textContainerClass()}>
|
||||||
<Show
|
<Show
|
||||||
when={isAssistantMessage()}
|
when={isAssistantMessage()}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@@ -294,12 +295,15 @@ 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, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
import { loadMessages, sendMessage, forkSession, 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,15 +217,10 @@ 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,6 +5,7 @@ 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")
|
||||||
|
|
||||||
@@ -92,6 +93,9 @@ 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,16 +235,12 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
restoreScrollPosition(autoScroll())
|
restoreScrollPosition(autoScroll())
|
||||||
if (!expanded()) return
|
if (!expanded()) return
|
||||||
scheduleAnchorScroll(true)
|
scheduleAnchorScroll()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||||
const next = element || undefined
|
scrollContainerRef = element || undefined
|
||||||
if (next === scrollContainerRef) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollContainerRef = next
|
|
||||||
setScrollContainer(scrollContainerRef)
|
setScrollContainer(scrollContainerRef)
|
||||||
if (scrollContainerRef) {
|
if (scrollContainerRef) {
|
||||||
restoreScrollPosition(autoScroll())
|
restoreScrollPosition(autoScroll())
|
||||||
@@ -597,7 +593,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousPartVersion = version
|
previousPartVersion = version
|
||||||
scheduleAnchorScroll(true)
|
scheduleAnchorScroll()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
|
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} 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,14 +26,6 @@ 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
|
||||||
@@ -43,8 +35,6 @@ 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 = (() => {
|
||||||
@@ -68,7 +58,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!disableScrollTracking) {
|
if (!options?.disableScrollTracking) {
|
||||||
params.handleScrollRendered()
|
params.handleScrollRendered()
|
||||||
}
|
}
|
||||||
params.onContentRendered?.()
|
params.onContentRendered?.()
|
||||||
@@ -77,8 +67,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={registerRef}
|
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
onScroll={options?.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>
|
||||||
@@ -110,7 +100,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
cacheEntryParams={cacheEntryParams as any}
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
onRendered={handleDiffRendered}
|
onRendered={handleDiffRendered}
|
||||||
/>
|
/>
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,6 @@ 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
|
||||||
@@ -32,7 +24,6 @@ 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)
|
||||||
@@ -40,7 +31,7 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={messageClass}
|
class={messageClass}
|
||||||
ref={registerRef}
|
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||||
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>
|
||||||
@@ -65,7 +56,7 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={messageClass}
|
class={messageClass}
|
||||||
ref={registerRef}
|
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||||
>
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
if (!props.active()) return
|
if (!props.active()) return
|
||||||
const rawValue = input?.value ?? ""
|
const rawValue = input?.value ?? ""
|
||||||
const value = rawValue
|
const value = rawValue
|
||||||
if (value.trim().length === 0) return
|
|
||||||
|
|
||||||
const info = questions()[questionIndex]
|
const info = questions()[questionIndex]
|
||||||
const multi = info?.multiple === true
|
const multi = info?.multiple === true
|
||||||
@@ -121,6 +120,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
updateAnswer(questionIndex, [])
|
updateAnswer(questionIndex, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the custom field is empty, treat it as unanswered.
|
||||||
|
// This prevents submitting a previously selected option when the user
|
||||||
|
// has explicitly switched focus to the custom input.
|
||||||
|
if (value.trim().length === 0) return
|
||||||
|
|
||||||
|
if (multi) {
|
||||||
|
const existing = answers()[questionIndex] ?? []
|
||||||
|
if (!existing.includes(value)) {
|
||||||
|
updateAnswer(questionIndex, [...existing, value])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
toggleOption(questionIndex, value)
|
toggleOption(questionIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +293,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.isComposing) {
|
if (e.key === "Enter" && !e.isComposing) {
|
||||||
|
// Don't submit if the custom field is empty (common when switching to it).
|
||||||
|
if (e.currentTarget.value.trim().length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
if (!submitDisabled()) {
|
if (!submitDisabled()) {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={scrollHelpers?.registerContainer}
|
ref={(element) => scrollHelpers?.registerContainer(element)}
|
||||||
onScroll={
|
onScroll={
|
||||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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"
|
||||||
|
|
||||||
@@ -22,31 +21,6 @@ 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,
|
||||||
{
|
{
|
||||||
@@ -106,13 +80,14 @@ 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 && (
|
||||||
<button
|
<a
|
||||||
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"
|
||||||
onClick={() => void openExternalUrl(payload.action!.href)}
|
href={payload.action.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{payload.action.label}
|
{payload.action.label}
|
||||||
</button>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -132,13 +132,6 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -489,3 +482,4 @@
|
|||||||
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