diff --git a/package-lock.json b/package-lock.json index aeaea7e3..597aa86f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.9.2", + "version": "0.9.3", "dependencies": { "7zip-bin": "^5.2.0", "google-auth-library": "^10.5.0" @@ -1419,6 +1419,16 @@ "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": { "version": "2.9.4", "dev": true, @@ -1462,6 +1472,15 @@ "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": { "version": "2.0.0", "dev": true, @@ -7384,7 +7403,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.9.2", + "version": "0.9.3", "dependencies": { "@codenomad/ui": "file:../ui", "@neuralnomads/codenomad": "file:../server" @@ -7418,7 +7437,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.9.2", + "version": "0.9.3", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", @@ -7455,14 +7474,14 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.9.2", + "version": "0.9.3", "devDependencies": { "@tauri-apps/cli": "^2.9.4" } }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.9.2", + "version": "0.9.3", "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", @@ -7471,6 +7490,7 @@ "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", "@suid/system": "^0.14.0", + "@tauri-apps/plugin-opener": "^2.5.3", "ansi-sequence-parser": "^1.1.3", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", diff --git a/package.json b/package.json index a2247d03..6dcd9d9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.9.2", + "version": "0.9.3", "private": true, "description": "CodeNomad monorepo workspace", "workspaces": { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 11cae7a6..b3906dfe 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.9.2", + "version": "0.9.3", "description": "CodeNomad - AI coding assistant", "author": { "name": "Neural Nomads", diff --git a/packages/server/README.md b/packages/server/README.md index 0649aa22..e69e9059 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -51,8 +51,17 @@ You can configure the server using flags or environment variables: | `--config ` | `CLI_CONFIG` | Config file location | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--log-level ` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | +| `--username ` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) | +| `--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 - **Config**: `~/.config/codenomad/config.json` - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 79c07fbf..c8c33586 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.9.2", + "version": "0.9.3", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 9d4d581b..26804ce8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.9.2", + "version": "0.9.3", "description": "CodeNomad Server", "author": { "name": "Neural Nomads", diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index 55014d55..ebb7ec79 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -15,15 +15,25 @@ export interface AuthManagerInit { username: string password?: string generateToken: boolean + dangerouslySkipAuth?: boolean } export class AuthManager { - private readonly authStore: AuthStore + private readonly authStore: AuthStore | null private readonly tokenManager: TokenManager | null private readonly sessionManager = new SessionManager() private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME + private readonly authEnabled: boolean constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { + this.authEnabled = !Boolean(init.dangerouslySkipAuth) + + if (!this.authEnabled) { + this.authStore = null + this.tokenManager = null + return + } + const authFilePath = resolveAuthFilePath(init.configPath) this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" })) @@ -37,6 +47,10 @@ export class AuthManager { this.tokenManager = init.generateToken ? new TokenManager(60_000) : null } + isAuthEnabled(): boolean { + return this.authEnabled + } + getCookieName(): string { return this.cookieName } @@ -56,19 +70,31 @@ export class AuthManager { } validateLogin(username: string, password: string): boolean { - return this.authStore.validateCredentials(username, password) + if (!this.authEnabled) { + return true + } + return this.requireAuthStore().validateCredentials(username, password) } createSession(username: string) { + if (!this.authEnabled) { + return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username } + } return this.sessionManager.createSession(username) } getStatus() { - return this.authStore.getStatus() + if (!this.authEnabled) { + return { username: this.init.username, passwordUserProvided: false } + } + return this.requireAuthStore().getStatus() } setPassword(password: string) { - return this.authStore.setPassword({ password, markUserProvided: true }) + if (!this.authEnabled) { + throw new Error("Internal authentication is disabled") + } + return this.requireAuthStore().setPassword({ password, markUserProvided: true }) } isLoopbackRequest(request: FastifyRequest): boolean { @@ -76,6 +102,12 @@ export class AuthManager { } getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null { + if (!this.authEnabled) { + // When auth is disabled, treat all requests as authenticated. + // We still return a stable username so callers can display it. + return { username: this.init.username, sessionId: "auth-disabled" } + } + const cookies = parseCookies(request.headers.cookie) const sessionId = cookies[this.cookieName] const session = this.sessionManager.getSession(sessionId) @@ -90,6 +122,13 @@ export class AuthManager { clearSessionCookie(reply: FastifyReply) { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 })) } + + private requireAuthStore(): AuthStore { + if (!this.authStore) { + throw new Error("Auth store is unavailable") + } + return this.authStore + } } function resolveAuthFilePath(configPath: string) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 389b6198..9adffec4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -44,6 +44,7 @@ interface CliOptions { authUsername: string authPassword?: string generateToken: boolean + dangerouslySkipAuth: boolean } const DEFAULT_PORT = 9898 @@ -84,6 +85,14 @@ function parseCliOptions(argv: string[]): CliOptions { .env("CODENOMAD_GENERATE_TOKEN") .default(false), ) + .addOption( + new Option( + "--dangerously-skip-auth", + "Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).", + ) + .env("CODENOMAD_SKIP_AUTH") + .default(false), + ) program.parse(argv, { from: "user" }) const parsed = program.opts<{ @@ -104,8 +113,14 @@ function parseCliOptions(argv: string[]): CliOptions { username: string password?: string generateToken?: boolean + dangerouslySkipAuth?: boolean }>() + const parseBooleanEnv = (value: string | undefined): boolean => { + const normalized = (value ?? "").trim().toLowerCase() + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on" + } + const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() const normalizedHost = resolveHost(parsed.host) @@ -130,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions { authUsername: parsed.username, authPassword: parsed.password, generateToken: Boolean(parsed.generateToken), + dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), } } @@ -174,6 +190,12 @@ async function main() { logger.info({ options: logOptions }, "Starting CodeNomad CLI server") + if (options.dangerouslySkipAuth) { + logger.warn( + "DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).", + ) + } + const eventBus = new EventBus(eventLogger) const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") @@ -195,11 +217,12 @@ async function main() { username: options.authUsername, password: options.authPassword, generateToken: options.generateToken, + dangerouslySkipAuth: options.dangerouslySkipAuth, }, logger.child({ component: "auth" }), ) - if (options.generateToken) { + if (options.generateToken && !options.dangerouslySkipAuth) { const token = authManager.issueBootstrapToken() if (token) { console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 65ef3472..0da95d16 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -380,6 +380,16 @@ async function proxyWorkspaceRequest(args: { if (instanceAuthHeader) { headers.authorization = instanceAuthHeader } + + // Enforce per-workspace directory scoping for all proxied OpenCode requests. + // OpenCode expects the *full* path; we send it via header to avoid query tampering. + const directory = workspace.path + const isNonASCII = /[^\x00-\x7F]/.test(directory) + const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory + + // Overwrite any client-provided value (case-insensitive headers are normalized by Node). + ;(headers as Record)["x-opencode-directory"] = encodedDirectory + return headers }, onError: (proxyReply, { error }) => { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 95dd9c0f..69c7f685 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.9.2", + "version": "0.9.3", "private": true, "scripts": { "dev": "tauri dev", diff --git a/packages/tauri-app/src-tauri/capabilities/main-window.json b/packages/tauri-app/src-tauri/capabilities/main-window.json index 61d6e346..14cb2d7f 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -3,7 +3,7 @@ "identifier": "main-window-native-dialogs", "description": "Grant the main window access to required core features and native dialog commands.", "remote": { - "urls": ["http://127.0.0.1:*", "http://localhost:*"] + "urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"] }, "windows": ["main"], "permissions": [ diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index c98bf3f4..1e38e0c9 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index a1b8013a..3656277c 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -464,13 +464,33 @@ impl CliProcessManager { let status_clone = status.clone(); let app_clone = app.clone(); thread::spawn(move || { - let code = { - let mut guard = child_holder.lock(); - if let Some(child) = guard.as_mut() { - child.wait().ok() - } else { - None + // Do not hold the child mutex while waiting for process exit. + // Holding the lock across `wait()` deadlocks `stop()`, which needs the + // same lock to send SIGTERM/SIGKILL when the user quits the app. + let code = loop { + let maybe_exited = { + let mut guard = child_holder.lock(); + if guard.is_none() { + return; + } + match guard + .as_mut() + .and_then(|child| child.try_wait().ok().flatten()) + { + Some(status) => { + // Drop the handle after the process exits so other callers + // don't attempt to stop/kill a finished process. + *guard = None; + Some(status) + } + None => None, + } + }; + + if let Some(status) = maybe_exited { + break Some(status); } + thread::sleep(Duration::from_millis(100)); }; let mut locked = status_clone.lock(); diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 81645233..6cc35251 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -4,6 +4,7 @@ mod cli_manager; use cli_manager::{CliProcessManager, CliStatus}; use serde_json::json; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::webview::Webview; @@ -11,6 +12,8 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry}; use tauri_plugin_opener::OpenerExt; use url::Url; +static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); + #[derive(Clone)] pub struct AppState { pub manager: CliProcessManager, @@ -39,7 +42,10 @@ fn is_dev_mode() -> bool { fn should_allow_internal(url: &Url) -> bool { match url.scheme() { "tauri" | "asset" | "file" => true, - "http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")), + // On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`. + // This must be treated as an internal origin or the navigation guard will + // redirect it to the system browser and the app will appear blank. + "http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")), _ => false, } } @@ -164,6 +170,11 @@ fn main() { .expect("error while building tauri application") .run(|app_handle, event| match event { tauri::RunEvent::ExitRequested { api, .. } => { + // `app_handle.exit(0)` triggers another `ExitRequested`. Without a guard, we can + // prevent exit forever and the app never quits (Cmd+Q / Quit menu appears stuck). + if QUIT_REQUESTED.swap(true, Ordering::SeqCst) { + return; + } api.prevent_exit(); let app = app_handle.clone(); std::thread::spawn(move || { @@ -178,6 +189,9 @@ fn main() { .. } => { // Ensure we have time to stop the CLI process before the app exits. + if QUIT_REQUESTED.swap(true, Ordering::SeqCst) { + return; + } api.prevent_close(); let app = app_handle.clone(); std::thread::spawn(move || { diff --git a/packages/ui/package.json b/packages/ui/package.json index 47caede3..cc8aa45b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.9.2", + "version": "0.9.3", "private": true, "type": "module", "scripts": { @@ -17,6 +17,7 @@ "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", "@suid/system": "^0.14.0", + "@tauri-apps/plugin-opener": "^2.5.3", "ansi-sequence-parser": "^1.1.3", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index 8a8c4def..a06568d9 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -5,7 +5,6 @@ import { ChevronDown } from "lucide-solid" import type { Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" -import Kbd from "./kbd" const log = getLogger("session") @@ -113,9 +112,6 @@ export default function AgentSelector(props: AgentSelectorProps) { )} - diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index dedcd12e..308cb81c 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -88,9 +88,9 @@ interface InstanceShellProps { tabBarOffset: number } -const DEFAULT_SESSION_SIDEBAR_WIDTH = 280 +const DEFAULT_SESSION_SIDEBAR_WIDTH = 340 const MIN_SESSION_SIDEBAR_WIDTH = 220 -const MAX_SESSION_SIDEBAR_WIDTH = 360 +const MAX_SESSION_SIDEBAR_WIDTH = 400 const RIGHT_DRAWER_WIDTH = 260 const MIN_RIGHT_DRAWER_WIDTH = 200 const MAX_RIGHT_DRAWER_WIDTH = 380 @@ -936,6 +936,12 @@ const InstanceShell2: Component = (props) => { /> + + )} diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index b7b47820..a9a01eea 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -172,21 +172,212 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches) interface ContentDisplayItem { type: "content" key: string - record: MessageRecord - parts: ClientPart[] - messageInfo?: MessageInfo - isQueued: boolean - showAgentMeta?: boolean + messageId: string + startPartId: string } interface ToolDisplayItem { type: "tool" key: string - toolPart: ToolCallPart - messageInfo?: MessageInfo messageId: string - messageVersion: number - partVersion: number + partId: string +} + +interface MessageContentItemProps { + instanceId: string + sessionId: string + store: () => InstanceMessageStore + messageId: string + startPartId: string + messageIndex: number + lastAssistantIndex: () => number + onRevert?: (messageId: string) => void + onFork?: (messageId?: string) => void + onContentRendered?: () => void +} + +function MessageContentItem(props: MessageContentItemProps) { + const record = createMemo(() => props.store().getMessage(props.messageId)) + const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) + + const isQueued = createMemo(() => { + const current = record() + if (!current) return false + if (current.role !== "user") return false + const lastAssistant = props.lastAssistantIndex() + return lastAssistant === -1 || props.messageIndex > lastAssistant + }) + + const parts = createMemo(() => { + 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 ( + + {(resolvedRecord) => ( + + )} + + ) +} + +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 ( + + {(resolvedToolPart) => ( + <> +
+
+ {TOOL_ICON} + {t("messageBlock.tool.header")} + {toolName() || t("messageBlock.tool.unknown")} +
+ + + +
+ + + + )} +
+ ) } interface StepDisplayItem { @@ -272,7 +463,6 @@ export default function MessageBlock(props: MessageBlockProps) { const items: MessageBlockItem[] = [] const blockContentKeys: string[] = [] const blockToolKeys: string[] = [] - let segmentIndex = 0 let pendingParts: ClientPart[] = [] let agentMetaAttached = current.role !== "assistant" const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR @@ -280,34 +470,28 @@ export default function MessageBlock(props: MessageBlockProps) { const flushContent = () => { if (pendingParts.length === 0) return - const segmentKey = `${current.id}:segment:${segmentIndex}` - segmentIndex += 1 - const shouldShowAgentMeta = - current.role === "assistant" && - !agentMetaAttached && - pendingParts.some((part) => partHasRenderableText(part)) + const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : "" + if (!startPartId) { + pendingParts = [] + return + } + + if (!agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part))) { + agentMetaAttached = true + } + + const segmentKey = `${current.id}:content:${startPartId}` let cached = sessionCache.messageItems.get(segmentKey) if (!cached) { cached = { type: "content", key: segmentKey, - record: current, - parts: pendingParts.slice(), - messageInfo: info, - isQueued, - showAgentMeta: shouldShowAgentMeta, + messageId: current.id, + startPartId, } sessionCache.messageItems.set(segmentKey, cached) - } else { - cached.record = current - cached.parts = pendingParts.slice() - cached.messageInfo = info - cached.isQueued = isQueued - cached.showAgentMeta = shouldShowAgentMeta - } - if (shouldShowAgentMeta) { - agentMetaAttached = true } + items.push(cached) blockContentKeys.push(segmentKey) lastAccentColor = defaultAccentColor @@ -317,28 +501,26 @@ export default function MessageBlock(props: MessageBlockProps) { orderedParts.forEach((part, partIndex) => { if (part.type === "tool") { flushContent() - const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0 - const messageVersion = current.revision - const key = `${current.id}:${part.id ?? partIndex}` + const partId = part.id + if (!partId) { + // Tool parts are required to have ids; if one slips through, skip rendering + // to avoid unstable keys and accidental remount cascades. + return + } + const key = `${current.id}:${partId}` let toolItem = sessionCache.toolItems.get(key) if (!toolItem) { toolItem = { type: "tool", key, - toolPart: part as ToolCallPart, - messageInfo: info, messageId: current.id, - messageVersion, - partVersion, + partId, } sessionCache.toolItems.set(key, toolItem) } else { toolItem.key = key - toolItem.toolPart = part as ToolCallPart - toolItem.messageInfo = info toolItem.messageId = current.id - toolItem.messageVersion = messageVersion - toolItem.partVersion = partVersion + toolItem.partId = partId } items.push(toolItem) blockToolKeys.push(key) @@ -427,21 +609,21 @@ export default function MessageBlock(props: MessageBlockProps) { }) return ( - + {(resolvedBlock) => ( -
- +
+ {(item) => ( - {(() => { 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 (
-
-
- {TOOL_ICON} - {t("messageBlock.tool.header")} - {toolItem.toolPart.tool || t("messageBlock.tool.unknown")} -
- - - -
-
diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 7f3cb0a7..e066e34e 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -137,8 +137,17 @@ export default function MessageItem(props: MessageItemProps) { } const isGenerating = () => { + if (hasContent()) { + return false + } + + // Prefer the local record status for streaming placeholders. + if (!isUser() && props.record.status === "streaming") { + return true + } + const info = props.messageInfo - return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0 + return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0) } const handleRevert = () => { @@ -163,7 +172,7 @@ export default function MessageItem(props: MessageItemProps) { setTimeout(() => setCopied(false), 2000) } - if (!isUser() && !hasContent()) { + if (!isUser() && !hasContent() && !isGenerating()) { return null } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 421985bf..50e7759f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -25,6 +25,13 @@ interface MessagePartProps { const isAssistantMessage = () => props.messageType === "assistant" const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text") + const shouldHideTextPart = () => { + const part = props.part + if (!part || part.type !== "text") return false + // Keep optimistic user prompts visible; hide synthetic assistant text. + return Boolean((part as any).synthetic) && props.messageType !== "user" + } + const plainTextContent = () => { const part = props.part @@ -94,7 +101,7 @@ interface MessagePartProps { return ( - +
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })} - {currentModelValue() && ( + {currentModelValue() && ( {currentModelValue()!.providerId}/{currentModelValue()!.id} )}
- diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 12e7b92f..df0e6bc1 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -9,7 +9,7 @@ import PromptInput from "../prompt-input" import type { Attachment as PromptAttachment } from "../../types/attachment" import { getAttachments, removeAttachment } from "../../stores/attachments" import { instances } from "../../stores/instances" -import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" +import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { showAlertDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" @@ -217,10 +217,15 @@ export const SessionView: Component = (props) => { } const restoredText = getUserMessageText(messageId) + const parentTitle = (session()?.title ?? "").trim() || t("sessionList.session.untitled") try { const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId }) + renameSession(props.instanceId, forkedSession.id, `Fork: ${parentTitle}`).catch((error) => { + log.error("Failed to rename forked session", error) + }) + const parentToActivate = forkedSession.parentId ?? forkedSession.id setActiveParentSession(props.instanceId, parentToActivate) if (forkedSession.parentId) { diff --git a/packages/ui/src/components/thinking-selector.tsx b/packages/ui/src/components/thinking-selector.tsx index d648cf16..536f0131 100644 --- a/packages/ui/src/components/thinking-selector.tsx +++ b/packages/ui/src/components/thinking-selector.tsx @@ -5,7 +5,6 @@ import { ChevronDown } from "lucide-solid" import { getLogger } from "../lib/logger" import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences" import { useI18n } from "../lib/i18n" -import Kbd from "./kbd" const log = getLogger("session") @@ -93,9 +92,6 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
{triggerPrimary()}
- diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index e520a8d4..1814a373 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -235,12 +235,16 @@ export default function ToolCall(props: ToolCallProps) { requestAnimationFrame(() => { restoreScrollPosition(autoScroll()) if (!expanded()) return - scheduleAnchorScroll() + scheduleAnchorScroll(true) }) } const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { - scrollContainerRef = element || undefined + const next = element || undefined + if (next === scrollContainerRef) { + return + } + scrollContainerRef = next setScrollContainer(scrollContainerRef) if (scrollContainerRef) { restoreScrollPosition(autoScroll()) @@ -593,7 +597,7 @@ export default function ToolCall(props: ToolCallProps) { return } previousPartVersion = version - scheduleAnchorScroll() + scheduleAnchorScroll(true) }) createEffect(() => { diff --git a/packages/ui/src/components/tool-call/ansi-render.tsx b/packages/ui/src/components/tool-call/ansi-render.tsx index 2d7a49c1..8a5ed099 100644 --- a/packages/ui/src/components/tool-call/ansi-render.tsx +++ b/packages/ui/src/components/tool-call/ansi-render.tsx @@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: { } return ( -
params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> +
         {params.scrollHelpers.renderSentinel()}
       
diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 99113ef7..4f215c39 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -26,6 +26,14 @@ export function createDiffContentRenderer(params: { handleScrollRendered: () => void onContentRendered?: () => void }) { + const registerTracked = (element: HTMLDivElement | null) => { + params.scrollHelpers.registerContainer(element) + } + + const registerUntracked = (element: HTMLDivElement | null) => { + params.scrollHelpers.registerContainer(element, { disableTracking: true }) + } + function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null { const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = options?.label || (relativePath @@ -35,6 +43,8 @@ export function createDiffContentRenderer(params: { const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode const themeKey = params.isDark() ? "dark" : "light" + const disableScrollTracking = Boolean(options?.disableScrollTracking) + const registerRef = disableScrollTracking ? registerUntracked : registerTracked const baseEntryParams = cacheHandle.params() as any const cacheEntryParams = (() => { @@ -58,7 +68,7 @@ export function createDiffContentRenderer(params: { } const handleDiffRendered = () => { - if (!options?.disableScrollTracking) { + if (!disableScrollTracking) { params.handleScrollRendered() } params.onContentRendered?.() @@ -67,8 +77,8 @@ export function createDiffContentRenderer(params: { return (
params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })} - onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} + ref={registerRef} + onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} >
{toolbarLabel} @@ -100,7 +110,7 @@ export function createDiffContentRenderer(params: { cacheEntryParams={cacheEntryParams as any} onRendered={handleDiffRendered} /> - {params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })} + {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
) } diff --git a/packages/ui/src/components/tool-call/markdown-render.tsx b/packages/ui/src/components/tool-call/markdown-render.tsx index 36969901..c94859db 100644 --- a/packages/ui/src/components/tool-call/markdown-render.tsx +++ b/packages/ui/src/components/tool-call/markdown-render.tsx @@ -15,6 +15,14 @@ export function createMarkdownContentRenderer(params: { handleScrollRendered: () => void onContentRendered?: () => void }) { + const registerTracked = (element: HTMLDivElement | null) => { + params.scrollHelpers.registerContainer(element) + } + + const registerUntracked = (element: HTMLDivElement | null) => { + params.scrollHelpers.registerContainer(element, { disableTracking: true }) + } + function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null { if (!options.content) { return null @@ -24,6 +32,7 @@ export function createMarkdownContentRenderer(params: { const disableHighlight = options.disableHighlight || false const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` const disableScrollTracking = options.disableScrollTracking || false + const registerRef = disableScrollTracking ? registerUntracked : registerTracked const state = params.toolState() const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight) @@ -31,7 +40,7 @@ export function createMarkdownContentRenderer(params: { return (
params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })} + ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} >
{options.content}
@@ -56,7 +65,7 @@ export function createMarkdownContentRenderer(params: { return (
params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })} + ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} > { + if (props.active() && firstInputRef) { + firstInputRef.focus() + } + }) const requestId = createMemo(() => { const state = props.toolState() @@ -206,7 +213,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
- {(opt) => { + {(opt, optIndex) => { const checked = () => selected().includes(opt.label) return (
diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index c46473b3..3bba8ef7 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -176,7 +176,7 @@ export const taskRenderer: ToolRenderer = {
scrollHelpers?.registerContainer(element)} + ref={scrollHelpers?.registerContainer} onScroll={ scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined } diff --git a/packages/ui/src/lib/notifications.tsx b/packages/ui/src/lib/notifications.tsx index 675fc884..4222c69a 100644 --- a/packages/ui/src/lib/notifications.tsx +++ b/packages/ui/src/lib/notifications.tsx @@ -1,4 +1,5 @@ import toast from "solid-toast" +import { isTauriHost } from "./runtime-env" export type ToastVariant = "info" | "success" | "warning" | "error" @@ -21,6 +22,31 @@ export type ToastPayload = { } } +async function openExternalUrl(url: string): Promise { + 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< ToastVariant, { @@ -80,14 +106,13 @@ export function showToastNotification(payload: ToastPayload): ToastHandle { {payload.title &&

{payload.title}

}

{payload.message}

{payload.action && ( - void openExternalUrl(payload.action!.href)} > {payload.action.label} - + )}
diff --git a/packages/ui/src/styles/panels.css b/packages/ui/src/styles/panels.css index ba5f494e..83ce444b 100644 --- a/packages/ui/src/styles/panels.css +++ b/packages/ui/src/styles/panels.css @@ -132,6 +132,13 @@ 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 { @apply flex-shrink-0; } @@ -482,4 +489,3 @@ border-color: var(--border-base); background-color: var(--surface-secondary); } -