Compare commits

..

1 Commits

Author SHA1 Message Date
Shantur Rathore
abe96a7a8b fix(ui): keep submit disabled for empty custom answer 2026-01-28 15:32:12 +00:00
31 changed files with 166 additions and 506 deletions

32
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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.)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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}`)

View File

@@ -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 }) => {

View File

@@ -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",

View File

@@ -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": [

View File

@@ -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"]}}

View File

@@ -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();

View File

@@ -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 || {

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>
</> </>
)} )}

View File

@@ -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>

View File

@@ -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
} }

View File

@@ -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()}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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>

View File

@@ -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>
) )
} }

View File

@@ -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

View File

@@ -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()
} }

View File

@@ -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
} }

View File

@@ -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>

View File

@@ -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);
} }