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",
"version": "0.9.3",
"version": "0.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.9.3",
"version": "0.9.2",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -1419,16 +1419,6 @@
"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,
@@ -1472,15 +1462,6 @@
"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,
@@ -7403,7 +7384,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.3",
"version": "0.9.2",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -7437,7 +7418,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.9.3",
"version": "0.9.2",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -7474,14 +7455,14 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.9.3",
"version": "0.9.2",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.9.3",
"version": "0.9.2",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
@@ -7490,7 +7471,6 @@
"@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",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.9.3",
"version": "0.9.2",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.3",
"version": "0.9.2",
"description": "CodeNomad - AI coding assistant",
"author": {
"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 |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--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
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.9.3",
"version": "0.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.9.3",
"version": "0.9.2",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.9.3",
"version": "0.9.2",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",

View File

@@ -15,25 +15,15 @@ export interface AuthManagerInit {
username: string
password?: string
generateToken: boolean
dangerouslySkipAuth?: boolean
}
export class AuthManager {
private readonly authStore: AuthStore | null
private readonly authStore: AuthStore
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" }))
@@ -47,10 +37,6 @@ export class AuthManager {
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
isAuthEnabled(): boolean {
return this.authEnabled
}
getCookieName(): string {
return this.cookieName
}
@@ -70,31 +56,19 @@ export class AuthManager {
}
validateLogin(username: string, password: string): boolean {
if (!this.authEnabled) {
return true
}
return this.requireAuthStore().validateCredentials(username, password)
return this.authStore.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() {
if (!this.authEnabled) {
return { username: this.init.username, passwordUserProvided: false }
}
return this.requireAuthStore().getStatus()
return this.authStore.getStatus()
}
setPassword(password: string) {
if (!this.authEnabled) {
throw new Error("Internal authentication is disabled")
}
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
return this.authStore.setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
@@ -102,12 +76,6 @@ 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)
@@ -122,13 +90,6 @@ 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) {

View File

@@ -44,7 +44,6 @@ interface CliOptions {
authUsername: string
authPassword?: string
generateToken: boolean
dangerouslySkipAuth: boolean
}
const DEFAULT_PORT = 9898
@@ -85,14 +84,6 @@ 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<{
@@ -113,14 +104,8 @@ 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)
@@ -145,7 +130,6 @@ function parseCliOptions(argv: string[]): CliOptions {
authUsername: parsed.username,
authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
}
}
@@ -190,12 +174,6 @@ 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.")
@@ -217,12 +195,11 @@ async function main() {
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth,
},
logger.child({ component: "auth" }),
)
if (options.generateToken && !options.dangerouslySkipAuth) {
if (options.generateToken) {
const token = authManager.issueBootstrapToken()
if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)

View File

@@ -380,16 +380,6 @@ 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<string, unknown>)["x-opencode-directory"] = encodedDirectory
return headers
},
onError: (proxyReply, { error }) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.9.3",
"version": "0.9.2",
"private": true,
"scripts": {
"dev": "tauri dev",

View File

@@ -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:*", "http://tauri.localhost/*", "https://tauri.localhost/*"]
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"windows": ["main"],
"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 app_clone = app.clone();
thread::spawn(move || {
// 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);
let code = {
let mut guard = child_holder.lock();
if let Some(child) = guard.as_mut() {
child.wait().ok()
} else {
None
}
thread::sleep(Duration::from_millis(100));
};
let mut locked = status_clone.lock();

View File

@@ -4,7 +4,6 @@ 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;
@@ -12,8 +11,6 @@ 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,
@@ -42,10 +39,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
// 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")),
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
_ => false,
}
}
@@ -170,11 +164,6 @@ 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 || {
@@ -189,9 +178,6 @@ 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 || {

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.9.3",
"version": "0.9.2",
"private": true,
"type": "module",
"scripts": {
@@ -17,7 +17,6 @@
"@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",

View File

@@ -5,6 +5,7 @@ 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")
@@ -112,6 +113,9 @@ export default function AgentSelector(props: AgentSelectorProps) {
)}
</Select.Value>
</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">
<ChevronDown class="w-3 h-3" />
</Select.Icon>

View File

@@ -88,9 +88,9 @@ interface InstanceShellProps {
tabBarOffset: number
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const MIN_SESSION_SIDEBAR_WIDTH = 220
const MAX_SESSION_SIDEBAR_WIDTH = 400
const MAX_SESSION_SIDEBAR_WIDTH = 360
const RIGHT_DRAWER_WIDTH = 260
const MIN_RIGHT_DRAWER_WIDTH = 200
const MAX_RIGHT_DRAWER_WIDTH = 380
@@ -936,12 +936,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
/>
<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>
</>
)}

View File

@@ -172,212 +172,21 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface ContentDisplayItem {
type: "content"
key: string
messageId: string
startPartId: string
record: MessageRecord
parts: ClientPart[]
messageInfo?: MessageInfo
isQueued: boolean
showAgentMeta?: boolean
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
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<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>
)
messageVersion: number
partVersion: number
}
interface StepDisplayItem {
@@ -463,6 +272,7 @@ 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
@@ -470,28 +280,34 @@ export default function MessageBlock(props: MessageBlockProps) {
const flushContent = () => {
if (pendingParts.length === 0) return
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}`
const segmentKey = `${current.id}:segment:${segmentIndex}`
segmentIndex += 1
const shouldShowAgentMeta =
current.role === "assistant" &&
!agentMetaAttached &&
pendingParts.some((part) => partHasRenderableText(part))
let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) {
cached = {
type: "content",
key: segmentKey,
messageId: current.id,
startPartId,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
}
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
@@ -501,26 +317,28 @@ export default function MessageBlock(props: MessageBlockProps) {
orderedParts.forEach((part, partIndex) => {
if (part.type === "tool") {
flushContent()
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}`
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
const messageVersion = current.revision
const key = `${current.id}:${part.id ?? partIndex}`
let toolItem = sessionCache.toolItems.get(key)
if (!toolItem) {
toolItem = {
type: "tool",
key,
toolPart: part as ToolCallPart,
messageInfo: info,
messageId: current.id,
partId,
messageVersion,
partVersion,
}
sessionCache.toolItems.set(key, toolItem)
} else {
toolItem.key = key
toolItem.toolPart = part as ToolCallPart
toolItem.messageInfo = info
toolItem.messageId = current.id
toolItem.partId = partId
toolItem.messageVersion = messageVersion
toolItem.partVersion = partVersion
}
items.push(toolItem)
blockToolKeys.push(key)
@@ -609,21 +427,21 @@ export default function MessageBlock(props: MessageBlockProps) {
})
return (
<Show when={block()}>
<Show when={block()} keyed>
{(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
<For each={resolvedBlock().items}>
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
<For each={resolvedBlock.items}>
{(item) => (
<Switch>
<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}
sessionId={props.sessionId}
store={props.store}
messageId={(item as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
isQueued={(item as ContentDisplayItem).isQueued}
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
@@ -632,14 +450,46 @@ export default function MessageBlock(props: MessageBlockProps) {
<Match when={item.type === "tool"}>
{(() => {
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 (
<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}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
onContentRendered={props.onContentRendered}
/>
</div>

View File

@@ -137,17 +137,8 @@ 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 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 = () => {
@@ -172,7 +163,7 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000)
}
if (!isUser() && !hasContent() && !isGenerating()) {
if (!isUser() && !hasContent()) {
return null
}

View File

@@ -25,13 +25,6 @@ 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
@@ -101,7 +94,7 @@ interface MessagePartProps {
return (
<Switch>
<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()}>
<Show
when={isAssistantMessage()}

View File

@@ -6,6 +6,7 @@ import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
import Kbd from "./kbd"
const log = getLogger("session")
@@ -294,12 +295,15 @@ export default function ModelSelector(props: ModelSelectorProps) {
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span>
{currentModelValue() && (
{currentModelValue() && (
<span class="selector-trigger-secondary">
{currentModelValue()!.providerId}/{currentModelValue()!.id}
</span>
)}
</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">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>

View File

@@ -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, 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 { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
@@ -217,15 +217,10 @@ export const SessionView: Component<SessionViewProps> = (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) {

View File

@@ -5,6 +5,7 @@ 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")
@@ -92,6 +93,9 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
<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>
</div>
<span class="selector-trigger-hint" aria-hidden="true">
<Kbd shortcut="cmd+shift+t" />
</span>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>

View File

@@ -235,16 +235,12 @@ export default function ToolCall(props: ToolCallProps) {
requestAnimationFrame(() => {
restoreScrollPosition(autoScroll())
if (!expanded()) return
scheduleAnchorScroll(true)
scheduleAnchorScroll()
})
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
scrollContainerRef = element || undefined
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
restoreScrollPosition(autoScroll())
@@ -597,7 +593,7 @@ export default function ToolCall(props: ToolCallProps) {
return
}
previousPartVersion = version
scheduleAnchorScroll(true)
scheduleAnchorScroll()
})
createEffect(() => {

View File

@@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: {
}
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} />
{params.scrollHelpers.renderSentinel()}
</div>

View File

@@ -26,14 +26,6 @@ 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
@@ -43,8 +35,6 @@ 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 = (() => {
@@ -68,7 +58,7 @@ export function createDiffContentRenderer(params: {
}
const handleDiffRendered = () => {
if (!disableScrollTracking) {
if (!options?.disableScrollTracking) {
params.handleScrollRendered()
}
params.onContentRendered?.()
@@ -77,8 +67,8 @@ export function createDiffContentRenderer(params: {
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<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>
@@ -110,7 +100,7 @@ export function createDiffContentRenderer(params: {
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
</div>
)
}

View File

@@ -15,14 +15,6 @@ 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
@@ -32,7 +24,6 @@ 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)
@@ -40,7 +31,7 @@ export function createMarkdownContentRenderer(params: {
return (
<div
class={messageClass}
ref={registerRef}
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
@@ -65,7 +56,7 @@ export function createMarkdownContentRenderer(params: {
return (
<div
class={messageClass}
ref={registerRef}
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<Markdown

View File

@@ -112,7 +112,6 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
if (!props.active()) return
const rawValue = input?.value ?? ""
const value = rawValue
if (value.trim().length === 0) return
const info = questions()[questionIndex]
const multi = info?.multiple === true
@@ -121,6 +120,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
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)
}
@@ -281,6 +293,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
onKeyDown={(e) => {
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()) {
props.onSubmit()
}

View File

@@ -176,7 +176,7 @@ export const taskRenderer: ToolRenderer = {
<div class="tool-call-task-section-body">
<div
class="message-text tool-call-markdown tool-call-task-container"
ref={scrollHelpers?.registerContainer}
ref={(element) => scrollHelpers?.registerContainer(element)}
onScroll={
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
}

View File

@@ -1,5 +1,4 @@
import toast from "solid-toast"
import { isTauriHost } from "./runtime-env"
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<
ToastVariant,
{
@@ -106,13 +80,14 @@ export function showToastNotification(payload: ToastPayload): ToastHandle {
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
{payload.action && (
<button
type="button"
<a
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}
</button>
</a>
)}
</div>
</div>

View File

@@ -132,13 +132,6 @@
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;
}
@@ -489,3 +482,4 @@
border-color: var(--border-base);
background-color: var(--surface-secondary);
}