Compare commits

...

7 Commits

Author SHA1 Message Date
Shantur
1d953dfe64 feat(ui): add session reload action
Let users refresh a session transcript from the sidebar without reopening it. Reuse the existing forced message loading path so the reload behavior stays aligned with normal session hydration.
2026-03-31 14:32:45 +01:00
Shantur
42589464e5 feat(voice): support per-client conversation mode state 2026-03-31 12:39:29 +01:00
Shantur
197dee2aea Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 00:22:32 +01:00
Shantur
045d8da8b2 feat(voice): add spoken summary mode for conversation replies 2026-03-31 00:20:26 +01:00
Pascal André
c9bd4b7395 fix(tauri): stop stale UI assets from shadowing desktop builds (#258)
## Summary
- prefer the bundled desktop UI over the downloaded cache when both
report the same version, so rebuilt installers do not keep serving stale
frontend assets
- rebuild the server workspace during the Tauri prebuild step on every
desktop package build, matching Electron's correctness boundary for
fresh UI/server assets
- add a regression test covering the equal-version bundled-vs-downloaded
UI selection path

## Why
- local desktop rebuilds should reflect the latest server and UI code
without requiring users to manually clear cached assets
- packaged updates should keep favoring the freshly bundled frontend
when the cached copy is not actually newer

## Testing
- node --import tsx --test
packages/server/src/ui/__tests__/remote-ui.test.ts
- npm run build:tauri
2026-03-30 20:54:29 +01:00
Pascal André
41a5026331 fix(tauri): sync native app version with package releases (#257)
## Summary
- sync the Tauri native version metadata from
`packages/tauri-app/package.json` so release builds pick up workspace
version bumps like `0.13.1`
- update the checked-in Tauri `Cargo.toml` and `tauri.conf.json`
versions from `0.12.3` to `0.13.1`
- document the prebuild sync behavior in `BUILD.md`

## Testing
- `node packages/tauri-app/scripts/sync-tauri-version.js`
2026-03-30 20:52:37 +01:00
codenomadbot[bot]
d1a27ac31b fix(ui): escape raw HTML in user prompt messages (#260)
## Summary
- escape raw HTML when rendering user message markdown so prompt input
is shown as text instead of injected HTML
- keep assistant and tool markdown behavior unchanged by scoping the
escape behavior to user messages
- update markdown cache keys so escaped and non-escaped render output do
not collide

## Verification
- `npm run typecheck --workspace @codenomad/ui` *(fails in this
workspace because frontend dependencies are not installed)*
- `npm run build --workspace @codenomad/ui` *(fails in this workspace
because `vite` is not installed)*

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur <shantur@Mac.home>
2026-03-30 08:48:52 +01:00
28 changed files with 727 additions and 26 deletions

View File

@@ -22,7 +22,7 @@
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app", "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app", "build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version" "bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app"
}, },
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",

View File

@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process" import { createBackgroundProcessTools } from "./lib/background-process"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) { export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig() const config = getCodeNomadConfig()
const client = createCodeNomadClient(config) const client = createCodeNomadClient(config)
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
pingTs: (event.properties as any)?.ts, pingTs: (event.properties as any)?.ts,
}, },
}).catch(() => {}) }).catch(() => {})
return
}
if (event.type === "codenomad.voiceMode") {
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
} }
}) })
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
tool: { tool: {
...backgroundProcessTools, ...backgroundProcessTools,
}, },
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
return
}
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
},
async event(input: { event: any }) { async event(input: { event: any }) {
const opencodeEvent = input?.event const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return if (!opencodeEvent || typeof opencodeEvent !== "object") return
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
}, },
} }
} }
function buildVoiceModePrompt(): string {
return [
"Voice conversation mode is enabled.",
"Prepend your reply with a fenced code block using language `spoken`.",
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
"Do not add generic phrases about whether the user should read more.",
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
"After the `spoken` block, continue with your normal detailed response.",
"Example:",
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
].join("\n\n")
}

View File

@@ -240,6 +240,10 @@ export interface SpeechSynthesisResponse {
mimeType: string mimeType: string
} }
export interface VoiceModeStateResponse {
enabled: boolean
}
export type WorkspaceEventType = export type WorkspaceEventType =
| "workspace.created" | "workspace.created"
| "workspace.started" | "workspace.started"

View File

@@ -0,0 +1,128 @@
import type { Logger } from "../logger"
const STALE_CONNECTION_TIMEOUT_MS = 45000
const STALE_SWEEP_INTERVAL_MS = 5000
export interface ClientConnectionRef {
clientId: string
connectionId: string
}
export interface ClientConnectionRecord extends ClientConnectionRef {
key: string
connectedAt: number
lastSeenAt: number
}
type ConnectionChangeEvent = {
type: "connected" | "disconnected"
connection: ClientConnectionRecord
reason?: string
}
interface RegisteredConnection extends ClientConnectionRecord {
close: () => void
}
export class ClientConnectionManager {
private readonly connections = new Map<string, RegisteredConnection>()
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
private readonly sweepTimer: NodeJS.Timeout
constructor(private readonly logger: Logger) {
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
this.sweepTimer.unref?.()
}
shutdown(): void {
clearInterval(this.sweepTimer)
for (const connection of Array.from(this.connections.values())) {
this.disconnect(connection.key, "shutdown", false)
}
}
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
this.subscribers.add(listener)
return () => this.subscribers.delete(listener)
}
register(input: ClientConnectionRef & { close: () => void }): () => void {
const key = getConnectionKey(input)
const now = Date.now()
const existing = this.connections.get(key)
if (existing) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
this.disconnect(key, "replaced")
}
const connection: RegisteredConnection = {
key,
clientId: input.clientId,
connectionId: input.connectionId,
connectedAt: now,
lastSeenAt: now,
close: input.close,
}
this.connections.set(key, connection)
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
this.notify({ type: "connected", connection })
return () => this.disconnect(key, "closed")
}
pong(input: ClientConnectionRef): boolean {
const key = getConnectionKey(input)
const connection = this.connections.get(key)
if (!connection) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
return false
}
connection.lastSeenAt = Date.now()
return true
}
isConnected(input: ClientConnectionRef): boolean {
return this.connections.has(getConnectionKey(input))
}
private sweepStaleConnections(): void {
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
for (const connection of Array.from(this.connections.values())) {
if (connection.lastSeenAt > cutoff) continue
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
this.disconnect(connection.key, "timeout")
}
}
private disconnect(key: string, reason: string, invokeClose = true): void {
const connection = this.connections.get(key)
if (!connection) return
this.connections.delete(key)
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
if (invokeClose) {
try {
connection.close()
} catch (error) {
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
}
}
this.notify({ type: "disconnected", connection, reason })
}
private notify(event: ConnectionChangeEvent): void {
for (const subscriber of this.subscribers) {
try {
subscriber(event)
} catch (error) {
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
}
}
}
}
function getConnectionKey(input: ClientConnectionRef): string {
return `${input.clientId}:${input.connectionId}`
}

View File

@@ -0,0 +1,96 @@
import type { Logger } from "../logger"
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
import type { PluginChannelManager } from "./channel"
interface VoiceModeManagerOptions {
connections: ClientConnectionManager
channel: PluginChannelManager
logger: Logger
}
export class VoiceModeManager {
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
private readonly aggregateByInstance = new Map<string, boolean>()
constructor(private readonly options: VoiceModeManagerOptions) {
this.options.connections.subscribe((event) => {
if (event.type !== "disconnected") return
this.clearConnection(event.connection)
})
}
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
if (enabled && !this.options.connections.isConnected(connection)) {
this.options.logger.debug(
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
"Ignoring voice mode enable for disconnected client connection",
)
return
}
const key = getConnectionKey(connection)
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
if (enabled) {
current.add(key)
this.enabledConnectionsByInstance.set(instanceId, current)
} else if (current.delete(key)) {
if (current.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
} else {
this.enabledConnectionsByInstance.set(instanceId, current)
}
}
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
this.publishIfChanged(instanceId)
}
syncInstance(instanceId: string): void {
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
}
isEnabled(instanceId: string): boolean {
return this.aggregateByInstance.get(instanceId) === true
}
private clearConnection(connection: ClientConnectionRef): void {
const key = getConnectionKey(connection)
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
if (!enabledConnections.delete(key)) continue
if (enabledConnections.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
}
this.publishIfChanged(instanceId)
}
}
private publishIfChanged(instanceId: string): void {
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
const previous = this.aggregateByInstance.get(instanceId) === true
if (enabled === previous) return
if (enabled) {
this.aggregateByInstance.set(instanceId, true)
} else {
this.aggregateByInstance.delete(instanceId)
}
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
}
}
function buildVoiceModeEvent(enabled: boolean) {
return {
type: "codenomad.voiceMode",
properties: {
enabled,
formatVersion: "v1",
},
}
}
function getConnectionKey(connection: ClientConnectionRef): string {
return `${connection.clientId}:${connection.connectionId}`
}

View File

@@ -29,6 +29,9 @@ import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth" import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service" import type { SpeechService } from "../speech/service"
import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@@ -173,6 +176,13 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }), logger: deps.logger.child({ component: "background-processes" }),
}) })
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: deps.logger.child({ component: "voice-mode" }),
})
registerAuthRoutes(app, { authManager: deps.authManager }) registerAuthRoutes(app, { authManager: deps.authManager })
@@ -248,7 +258,12 @@ export function createHttpServer(deps: HttpServerDeps) {
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) registerEventRoutes(app, {
eventBus: deps.eventBus,
registerClient: registerSseClient,
logger: sseLogger,
connectionManager: clientConnectionManager,
})
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, { registerStorageRoutes(app, {
instanceStore: deps.instanceStore, instanceStore: deps.instanceStore,
@@ -256,7 +271,13 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
}) })
registerSpeechRoutes(app, { speechService: deps.speechService }) registerSpeechRoutes(app, { speechService: deps.speechService })
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger }) registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: proxyLogger,
channel: pluginChannel,
voiceModeManager,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -321,6 +342,7 @@ export function createHttpServer(deps: HttpServerDeps) {
}, },
stop: () => { stop: () => {
closeSseClients() closeSseClients()
clientConnectionManager.shutdown()
return app.close() return app.close()
}, },
} }

View File

@@ -1,19 +1,32 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod"
import { EventBus } from "../../events/bus" import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types" import { WorkspaceEventPayload } from "../../api-types"
import type { ClientConnectionManager } from "../../clients/connection-manager"
import { Logger } from "../../logger" import { Logger } from "../../logger"
interface RouteDeps { interface RouteDeps {
eventBus: EventBus eventBus: EventBus
registerClient: (cleanup: () => void) => () => void registerClient: (cleanup: () => void) => () => void
logger: Logger logger: Logger
connectionManager: ClientConnectionManager
} }
let nextClientId = 0 let nextClientId = 0
const ConnectionQuerySchema = z.object({
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
const PongBodySchema = ConnectionQuerySchema.extend({
pingTs: z.number().optional(),
})
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => { app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId const clientId = ++nextClientId
const connection = ConnectionQuerySchema.parse(request.query ?? {})
deps.logger.debug({ clientId }, "SSE client connected") deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*" const origin = request.headers.origin ?? "*"
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
const unsubscribe = deps.eventBus.onEvent(send) const unsubscribe = deps.eventBus.onEvent(send)
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
reply.raw.write(`:hb ${Date.now()}\n\n`) const ping = { ts: Date.now() }
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
}, 15000) }, 15000)
let closed = false let closed = false
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
const unregister = deps.registerClient(close) const unregister = deps.registerClient(close)
const unregisterConnection = deps.connectionManager.register({
...connection,
close,
})
const handleClose = () => { const handleClose = () => {
close() close()
unregister() unregister()
unregisterConnection()
} }
request.raw.on("close", handleClose) request.raw.on("close", handleClose)
request.raw.on("error", handleClose) request.raw.on("error", handleClose)
}) })
app.post("/api/client-connections/pong", (request, reply) => {
const body = PongBodySchema.parse(request.body ?? {})
if (!deps.connectionManager.pong(body)) {
reply.code(404).send({ error: "Client connection not found" })
return
}
reply.code(204).send()
})
} }

View File

@@ -1,15 +1,19 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import type { VoiceModeStateResponse } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager" import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus" import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel" import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers" import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
import { VoiceModeManager } from "../../plugins/voice-mode"
interface RouteDeps { interface RouteDeps {
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
channel: PluginChannelManager
voiceModeManager: VoiceModeManager
} }
const PluginEventSchema = z.object({ const PluginEventSchema = z.object({
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
properties: z.record(z.unknown()).optional(), properties: z.record(z.unknown()).optional(),
}) })
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { const VoiceModeStateSchema = z.object({
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) enabled: z.boolean(),
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => { app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id) const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) { if (!workspace) {
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.raw.flushHeaders?.() reply.raw.flushHeaders?.()
reply.hijack() reply.hijack()
const registration = channel.register(request.params.id, reply) const registration = deps.channel.register(request.params.id, reply)
deps.voiceModeManager.syncInstance(request.params.id)
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent()) deps.channel.send(request.params.id, buildPingEvent())
}, 15000) }, 15000)
const close = () => { const close = () => {
@@ -49,6 +58,22 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
request.raw.on("error", close) request.raw.on("error", close)
}) })
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const payload = VoiceModeStateSchema.parse(request.body ?? {})
deps.voiceModeManager.setEnabled(
request.params.id,
{ clientId: payload.clientId, connectionId: payload.connectionId },
payload.enabled,
)
return { enabled: payload.enabled }
})
const handleWildcard = async (request: any, reply: any) => { const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId) const workspace = deps.workspaceManager.get(workspaceId)

View File

@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
assert.equal(result.uiStaticDir, bundledDir) assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1") assert.equal(result.uiVersion, "0.8.1")
}) })
it("prefers bundled when bundled and downloaded versions are equal", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
}) })

View File

@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: currentResolved, uiStaticDir: currentResolved,
source: "downloaded", source: "downloaded",
uiVersion: await readUiVersion(currentResolved), uiVersion: await readUiVersion(currentResolved),
priority: 2, priority: 1,
}) })
} }
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: bundledResolved, uiStaticDir: bundledResolved,
source: "bundled", source: "bundled",
uiVersion: await readUiVersion(bundledResolved), uiVersion: await readUiVersion(bundledResolved),
priority: 1, priority: 2,
}) })
} }

View File

@@ -8,6 +8,7 @@
"dev:ui": "npm run dev --workspace @codenomad/ui", "dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js", "dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui", "dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js", "prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild", "bundle:server": "npm run prebuild",
"build": "tauri build" "build": "tauri build"

View File

@@ -56,11 +56,7 @@ async function ensureMonacoAssets() {
function ensureServerBuild() { function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist") const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public") const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) { console.log("[prebuild] rebuilding server workspace for desktop packaging...")
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", { execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot, cwd: workspaceRoot,
stdio: "inherit", stdio: "inherit",

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const packageJsonPath = path.join(root, "package.json")
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
const cargoLockPath = path.join(root, "Cargo.lock")
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
function readPackageVersion() {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing version in packages/tauri-app/package.json")
}
return packageJson.version
}
function syncCargoToml(version) {
const current = fs.readFileSync(cargoTomlPath, "utf8")
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoTomlPath, updated)
return true
}
function syncCargoLock(version) {
if (!fs.existsSync(cargoLockPath)) {
return false
}
const current = fs.readFileSync(cargoLockPath, "utf8")
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoLockPath, updated)
return true
}
function syncTauriConfig(version) {
const current = fs.readFileSync(tauriConfigPath, "utf8")
const config = JSON.parse(current)
if (config.version === version) {
return false
}
config.version = version
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
return true
}
function main() {
const version = readPackageVersion()
const changed = []
if (syncCargoToml(version)) {
changed.push(path.relative(root, cargoTomlPath))
}
if (syncCargoLock(version)) {
changed.push(path.relative(root, cargoLockPath))
}
if (syncTauriConfig(version)) {
changed.push(path.relative(root, tauriConfigPath))
}
if (changed.length === 0) {
console.log(`[sync-tauri-version] already aligned to ${version}`)
return
}
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[sync-tauri-version] failed: ${message}`)
process.exit(1)
}

View File

@@ -83,6 +83,7 @@ interface MarkdownProps {
isDark?: boolean isDark?: boolean
size?: "base" | "sm" | "tight" size?: "base" | "sm" | "tight"
disableHighlight?: boolean disableHighlight?: boolean
escapeRawHtml?: boolean
onRendered?: () => void onRendered?: () => void
} }
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
const text = decodeHtmlEntitiesLocally(rawText) const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text) const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text) const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}` const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey } return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
}) })
const cacheHandle = useGlobalCache({ const cacheHandle = useGlobalCache({
@@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) {
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved() const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}` return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
@@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) {
text: snapshot.text, text: snapshot.text,
html: renderedHtml, html: renderedHtml,
theme: snapshot.themeKey, theme: snapshot.themeKey,
mode: snapshot.version, mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
} }
setHtml(renderedHtml) setHtml(renderedHtml)
cacheHandle.set(cacheEntry) cacheHandle.set(cacheEntry)
@@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) {
markdown.setMarkdownTheme(snapshot.themeKey === "dark") markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, { const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled, suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
}) })
if (latestRequestKey === snapshot.requestKey) { if (latestRequestKey === snapshot.requestKey) {
@@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) {
createEffect(() => { createEffect(() => {
const snapshot = resolved() const snapshot = resolved()
latestRequestKey = snapshot.requestKey latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version return cache.theme === snapshot.themeKey && cache.mode === cacheMode
} }
const localCache = snapshot.part.renderCache const localCache = snapshot.part.renderCache

View File

@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"} size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered} onRendered={props.onRendered}
/> />
</Show> </Show>

View File

@@ -2,7 +2,7 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
import type { SessionStatus } from "../types/session" import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state" import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status" import { getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid" import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog" import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
@@ -14,6 +14,7 @@ import {
ensureSessionParentExpanded, ensureSessionParentExpanded,
getVisibleSessionIds, getVisibleSessionIds,
isSessionParentExpanded, isSessionParentExpanded,
loadMessages,
loading, loading,
renameSession, renameSession,
sessions as sessionStateSessions, sessions as sessionStateSessions,
@@ -53,6 +54,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set()) const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
const normalizeSessionLabel = (sessionId: string) => { const normalizeSessionLabel = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
@@ -213,6 +215,32 @@ const SessionList: Component<SessionListProps> = (props) => {
setRenameTarget({ id: sessionId, title: session.title ?? "", label }) setRenameTarget({ id: sessionId, title: session.title ?? "", label })
} }
const isSessionReloading = (sessionId: string) => reloadingSessionIds().has(sessionId)
const handleReloadSession = async (event: MouseEvent, sessionId: string) => {
event.stopPropagation()
if (isSessionReloading(sessionId)) return
setReloadingSessionIds((prev) => {
const next = new Set(prev)
next.add(sessionId)
return next
})
try {
await loadMessages(props.instanceId, sessionId, true)
} catch (error) {
log.error(`Failed to reload session ${sessionId}:`, error)
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })
} finally {
setReloadingSessionIds((prev) => {
const next = new Set(prev)
next.delete(sessionId)
return next
})
}
}
const closeRenameDialog = () => { const closeRenameDialog = () => {
setRenameTarget(null) setRenameTarget(null)
} }
@@ -493,6 +521,21 @@ const SessionList: Component<SessionListProps> = (props) => {
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
</span> </span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => handleReloadSession(event, rowProps.sessionId)}
role="button"
tabIndex={0}
aria-label={t("sessionList.actions.reload.ariaLabel")}
title={t("sessionList.actions.reload.title")}
>
<Show
when={!isSessionReloading(rowProps.sessionId)}
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
>
<RotateCw class="w-3 h-3" />
</Show>
</span>
<span <span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`} class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => { onClick={(event) => {

View File

@@ -11,6 +11,7 @@ import type {
SpeechSynthesisResponse, SpeechSynthesisResponse,
SpeechTranscriptionResponse, SpeechTranscriptionResponse,
ServerMeta, ServerMeta,
VoiceModeStateResponse,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceDescriptor, WorkspaceDescriptor,
WorkspaceFileResponse, WorkspaceFileResponse,
@@ -23,6 +24,7 @@ import type {
WorktreeMap, WorktreeMap,
WorktreeCreateRequest, WorktreeCreateRequest,
} from "../../../server/src/api-types" } from "../../../server/src/api-types"
import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger" import { getLogger } from "./logger"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
@@ -348,6 +350,19 @@ export const serverApi = {
{ method: "POST" }, { method: "POST" },
) )
}, },
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
const identity = getClientIdentity()
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
method: "POST",
body: JSON.stringify({ ...identity, enabled }),
})
},
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise<void> {
return request<void>("/api/client-connections/pong", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchBackgroundProcessOutput( fetchBackgroundProcessOutput(
instanceId: string, instanceId: string,
processId: string, processId: string,
@@ -372,9 +387,15 @@ export const serverApi = {
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`, `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
) )
}, },
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) { connectEvents(
sseLogger.info(`Connecting to ${EVENTS_URL}`) onEvent: (event: WorkspaceEventPayload) => void,
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any) onError?: () => void,
onPing?: (payload: { ts?: number }) => void,
) {
const identity = getClientIdentity()
const url = buildClientEventsUrl(identity)
sseLogger.info(`Connecting to ${url}`)
const source = new EventSource(url, { withCredentials: true } as any)
source.onmessage = (event) => { source.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload const payload = JSON.parse(event.data) as WorkspaceEventPayload
@@ -387,8 +408,26 @@ export const serverApi = {
sseLogger.warn("EventSource error, closing stream") sseLogger.warn("EventSource error, closing stream")
onError?.() onError?.()
} }
source.addEventListener("codenomad.client.ping", (event: MessageEvent) => {
try {
const payload = event.data ? (JSON.parse(event.data) as { ts?: number }) : {}
onPing?.(payload)
} catch (error) {
sseLogger.error("Failed to parse ping event", error)
}
})
return source return source
}, },
} }
function buildClientEventsUrl(identity: { clientId: string; connectionId: string }): string {
const url = new URL(EVENTS_URL, typeof window !== "undefined" ? window.location.origin : "http://localhost")
url.searchParams.set("clientId", identity.clientId)
url.searchParams.set("connectionId", identity.connectionId)
if (EVENTS_URL.startsWith("http://") || EVENTS_URL.startsWith("https://")) {
return url.toString()
}
return `${url.pathname}${url.search}`
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,58 @@
const CLIENT_ID_STORAGE_KEY = "codenomad.client-id"
const CONNECTION_ID_STORAGE_KEY = "codenomad.connection-id"
let cachedClientId: string | null = null
let cachedConnectionId: string | null = null
export function getClientIdentity(): { clientId: string; connectionId: string } {
return {
clientId: getOrCreateClientId(),
connectionId: getOrCreateConnectionId(),
}
}
function getOrCreateClientId(): string {
if (cachedClientId) return cachedClientId
cachedClientId = getOrCreateStoredValue(CLIENT_ID_STORAGE_KEY, window.localStorage)
return cachedClientId
}
function getOrCreateConnectionId(): string {
if (cachedConnectionId) return cachedConnectionId
cachedConnectionId = getOrCreateStoredValue(CONNECTION_ID_STORAGE_KEY, window.sessionStorage)
return cachedConnectionId
}
function getOrCreateStoredValue(key: string, storage: Storage): string {
if (typeof window === "undefined") {
return generateUUID()
}
try {
const existing = storage.getItem(key)
if (existing && existing.trim()) {
return existing.trim()
}
} catch {
return generateUUID()
}
const next = generateUUID()
try {
storage.setItem(key, next)
} catch {
// Ignore storage failures and fall back to the in-memory value.
}
return next
}
function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID()
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
const random = (Math.random() * 16) | 0
const value = char === "x" ? random : (random & 0x3) | 0x8
return value.toString(16)
})
}

View File

@@ -25,12 +25,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "New session", "sessionList.actions.newSession.title": "New session",
"sessionList.actions.copyId.ariaLabel": "Copy session ID", "sessionList.actions.copyId.ariaLabel": "Copy session ID",
"sessionList.actions.copyId.title": "Copy session ID", "sessionList.actions.copyId.title": "Copy session ID",
"sessionList.actions.reload.ariaLabel": "Reload session",
"sessionList.actions.reload.title": "Reload session",
"sessionList.actions.rename.ariaLabel": "Rename session", "sessionList.actions.rename.ariaLabel": "Rename session",
"sessionList.actions.rename.title": "Rename session", "sessionList.actions.rename.title": "Rename session",
"sessionList.actions.delete.ariaLabel": "Delete session", "sessionList.actions.delete.ariaLabel": "Delete session",
"sessionList.actions.delete.title": "Delete session", "sessionList.actions.delete.title": "Delete session",
"sessionList.copyId.success": "Session ID copied", "sessionList.copyId.success": "Session ID copied",
"sessionList.copyId.error": "Unable to copy session ID", "sessionList.copyId.error": "Unable to copy session ID",
"sessionList.reload.error": "Unable to reload session",
"sessionList.delete.error": "Unable to delete session", "sessionList.delete.error": "Unable to delete session",
"sessionList.delete.title": "Delete session", "sessionList.delete.title": "Delete session",
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.", "sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",

View File

@@ -25,12 +25,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "Nueva sesión", "sessionList.actions.newSession.title": "Nueva sesión",
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión", "sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
"sessionList.actions.copyId.title": "Copiar ID de sesión", "sessionList.actions.copyId.title": "Copiar ID de sesión",
"sessionList.actions.reload.ariaLabel": "Recargar sesión",
"sessionList.actions.reload.title": "Recargar sesión",
"sessionList.actions.rename.ariaLabel": "Renombrar sesión", "sessionList.actions.rename.ariaLabel": "Renombrar sesión",
"sessionList.actions.rename.title": "Renombrar sesión", "sessionList.actions.rename.title": "Renombrar sesión",
"sessionList.actions.delete.ariaLabel": "Eliminar sesión", "sessionList.actions.delete.ariaLabel": "Eliminar sesión",
"sessionList.actions.delete.title": "Eliminar sesión", "sessionList.actions.delete.title": "Eliminar sesión",
"sessionList.copyId.success": "ID de sesión copiado", "sessionList.copyId.success": "ID de sesión copiado",
"sessionList.copyId.error": "No se pudo copiar el ID de sesión", "sessionList.copyId.error": "No se pudo copiar el ID de sesión",
"sessionList.reload.error": "No se pudo recargar la sesión",
"sessionList.delete.error": "No se pudo eliminar la sesión", "sessionList.delete.error": "No se pudo eliminar la sesión",
"sessionList.delete.title": "Eliminar sesión", "sessionList.delete.title": "Eliminar sesión",
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.", "sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",

View File

@@ -25,12 +25,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "Nouvelle session", "sessionList.actions.newSession.title": "Nouvelle session",
"sessionList.actions.copyId.ariaLabel": "Copier l'ID de session", "sessionList.actions.copyId.ariaLabel": "Copier l'ID de session",
"sessionList.actions.copyId.title": "Copier l'ID de session", "sessionList.actions.copyId.title": "Copier l'ID de session",
"sessionList.actions.reload.ariaLabel": "Recharger la session",
"sessionList.actions.reload.title": "Recharger la session",
"sessionList.actions.rename.ariaLabel": "Renommer la session", "sessionList.actions.rename.ariaLabel": "Renommer la session",
"sessionList.actions.rename.title": "Renommer la session", "sessionList.actions.rename.title": "Renommer la session",
"sessionList.actions.delete.ariaLabel": "Supprimer la session", "sessionList.actions.delete.ariaLabel": "Supprimer la session",
"sessionList.actions.delete.title": "Supprimer la session", "sessionList.actions.delete.title": "Supprimer la session",
"sessionList.copyId.success": "ID de session copié", "sessionList.copyId.success": "ID de session copié",
"sessionList.copyId.error": "Impossible de copier l'ID de session", "sessionList.copyId.error": "Impossible de copier l'ID de session",
"sessionList.reload.error": "Impossible de recharger la session",
"sessionList.delete.error": "Impossible de supprimer la session", "sessionList.delete.error": "Impossible de supprimer la session",
"sessionList.delete.title": "Supprimer la session", "sessionList.delete.title": "Supprimer la session",
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.", "sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",

View File

@@ -25,12 +25,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "סשן חדש", "sessionList.actions.newSession.title": "סשן חדש",
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן", "sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
"sessionList.actions.copyId.title": "העתק מזהה סשן", "sessionList.actions.copyId.title": "העתק מזהה סשן",
"sessionList.actions.reload.ariaLabel": "טען מחדש סשן",
"sessionList.actions.reload.title": "טען מחדש סשן",
"sessionList.actions.rename.ariaLabel": "שנה שם סשן", "sessionList.actions.rename.ariaLabel": "שנה שם סשן",
"sessionList.actions.rename.title": "שנה שם סשן", "sessionList.actions.rename.title": "שנה שם סשן",
"sessionList.actions.delete.ariaLabel": "מחק סשן", "sessionList.actions.delete.ariaLabel": "מחק סשן",
"sessionList.actions.delete.title": "מחק סשן", "sessionList.actions.delete.title": "מחק סשן",
"sessionList.copyId.success": "מזהה סשן הועתק", "sessionList.copyId.success": "מזהה סשן הועתק",
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן", "sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
"sessionList.reload.error": "לא ניתן לטעון מחדש את הסשן",
"sessionList.delete.error": "לא ניתן למחוק סשן", "sessionList.delete.error": "לא ניתן למחוק סשן",
"sessionList.delete.title": "מחק סשן", "sessionList.delete.title": "מחק סשן",
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.", "sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",

View File

@@ -25,12 +25,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "新しいセッション", "sessionList.actions.newSession.title": "新しいセッション",
"sessionList.actions.copyId.ariaLabel": "セッション ID をコピー", "sessionList.actions.copyId.ariaLabel": "セッション ID をコピー",
"sessionList.actions.copyId.title": "セッション ID をコピー", "sessionList.actions.copyId.title": "セッション ID をコピー",
"sessionList.actions.reload.ariaLabel": "セッションを再読み込み",
"sessionList.actions.reload.title": "セッションを再読み込み",
"sessionList.actions.rename.ariaLabel": "セッション名を変更", "sessionList.actions.rename.ariaLabel": "セッション名を変更",
"sessionList.actions.rename.title": "セッション名を変更", "sessionList.actions.rename.title": "セッション名を変更",
"sessionList.actions.delete.ariaLabel": "セッションを削除", "sessionList.actions.delete.ariaLabel": "セッションを削除",
"sessionList.actions.delete.title": "セッションを削除", "sessionList.actions.delete.title": "セッションを削除",
"sessionList.copyId.success": "セッション ID をコピーしました", "sessionList.copyId.success": "セッション ID をコピーしました",
"sessionList.copyId.error": "セッション ID をコピーできません", "sessionList.copyId.error": "セッション ID をコピーできません",
"sessionList.reload.error": "セッションを再読み込みできません",
"sessionList.delete.error": "セッションを削除できません", "sessionList.delete.error": "セッションを削除できません",
"sessionList.delete.title": "セッションを削除", "sessionList.delete.title": "セッションを削除",
"sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。", "sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。",

View File

@@ -25,12 +25,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "Новая сессия", "sessionList.actions.newSession.title": "Новая сессия",
"sessionList.actions.copyId.ariaLabel": "Скопировать ID сессии", "sessionList.actions.copyId.ariaLabel": "Скопировать ID сессии",
"sessionList.actions.copyId.title": "Скопировать ID сессии", "sessionList.actions.copyId.title": "Скопировать ID сессии",
"sessionList.actions.reload.ariaLabel": "Обновить сессию",
"sessionList.actions.reload.title": "Обновить сессию",
"sessionList.actions.rename.ariaLabel": "Переименовать сессию", "sessionList.actions.rename.ariaLabel": "Переименовать сессию",
"sessionList.actions.rename.title": "Переименовать сессию", "sessionList.actions.rename.title": "Переименовать сессию",
"sessionList.actions.delete.ariaLabel": "Удалить сессию", "sessionList.actions.delete.ariaLabel": "Удалить сессию",
"sessionList.actions.delete.title": "Удалить сессию", "sessionList.actions.delete.title": "Удалить сессию",
"sessionList.copyId.success": "ID сессии скопирован", "sessionList.copyId.success": "ID сессии скопирован",
"sessionList.copyId.error": "Не удалось скопировать ID сессии", "sessionList.copyId.error": "Не удалось скопировать ID сессии",
"sessionList.reload.error": "Не удалось обновить сессию",
"sessionList.delete.error": "Не удалось удалить сессию", "sessionList.delete.error": "Не удалось удалить сессию",
"sessionList.delete.title": "Удалить сессию", "sessionList.delete.title": "Удалить сессию",
"sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.", "sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.",

View File

@@ -25,12 +25,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "新建会话", "sessionList.actions.newSession.title": "新建会话",
"sessionList.actions.copyId.ariaLabel": "复制会话 ID", "sessionList.actions.copyId.ariaLabel": "复制会话 ID",
"sessionList.actions.copyId.title": "复制会话 ID", "sessionList.actions.copyId.title": "复制会话 ID",
"sessionList.actions.reload.ariaLabel": "重新加载会话",
"sessionList.actions.reload.title": "重新加载会话",
"sessionList.actions.rename.ariaLabel": "重命名会话", "sessionList.actions.rename.ariaLabel": "重命名会话",
"sessionList.actions.rename.title": "重命名会话", "sessionList.actions.rename.title": "重命名会话",
"sessionList.actions.delete.ariaLabel": "删除会话", "sessionList.actions.delete.ariaLabel": "删除会话",
"sessionList.actions.delete.title": "删除会话", "sessionList.actions.delete.title": "删除会话",
"sessionList.copyId.success": "已复制会话 ID", "sessionList.copyId.success": "已复制会话 ID",
"sessionList.copyId.error": "无法复制会话 ID", "sessionList.copyId.error": "无法复制会话 ID",
"sessionList.reload.error": "无法重新加载会话",
"sessionList.delete.error": "无法删除会话", "sessionList.delete.error": "无法删除会话",
"sessionList.delete.title": "删除会话", "sessionList.delete.title": "删除会话",
"sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。", "sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。",

View File

@@ -11,6 +11,7 @@ let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light" let currentTheme: "light" | "dark" = "light"
let isInitialized = false let isInitialized = false
let highlightSuppressed = false let highlightSuppressed = false
let escapeRawHtmlEnabled = false
let rendererSetup = false let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
@@ -285,6 +286,14 @@ function setupRenderer(isDark: boolean) {
return `<code class="inline-code">${escapeHtml(decoded)}</code>` return `<code class="inline-code">${escapeHtml(decoded)}</code>`
} }
renderer.html = (html: string) => {
if (!escapeRawHtmlEnabled) {
return html
}
return escapeHtml(decodeHtmlEntities(html))
}
marked.use({ renderer }) marked.use({ renderer })
rendererSetup = true rendererSetup = true
} }
@@ -308,6 +317,7 @@ export async function renderMarkdown(
content: string, content: string,
options?: { options?: {
suppressHighlight?: boolean suppressHighlight?: boolean
escapeRawHtml?: boolean
}, },
): Promise<string> { ): Promise<string> {
if (!isInitialized) { if (!isInitialized) {
@@ -316,6 +326,7 @@ export async function renderMarkdown(
} }
const suppressHighlight = options?.suppressHighlight ?? false const suppressHighlight = options?.suppressHighlight ?? false
const escapeRawHtml = options?.escapeRawHtml ?? false
const decoded = decodeHtmlEntities(content) const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) { if (!suppressHighlight) {
@@ -324,13 +335,16 @@ export async function renderMarkdown(
} }
const previousSuppressed = highlightSuppressed const previousSuppressed = highlightSuppressed
const previousEscapeRawHtml = escapeRawHtmlEnabled
highlightSuppressed = suppressHighlight highlightSuppressed = suppressHighlight
escapeRawHtmlEnabled = escapeRawHtml
try { try {
// Proceed to parse immediately - highlighting will be available on next render // Proceed to parse immediately - highlighting will be available on next render
return marked.parse(decoded) as Promise<string> return marked.parse(decoded) as Promise<string>
} finally { } finally {
highlightSuppressed = previousSuppressed highlightSuppressed = previousSuppressed
escapeRawHtmlEnabled = previousEscapeRawHtml
} }
} }

View File

@@ -1,5 +1,6 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
import { serverApi } from "./api-client" import { serverApi } from "./api-client"
import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger" import { getLogger } from "./logger"
const RETRY_BASE_DELAY = 1000 const RETRY_BASE_DELAY = 1000
@@ -16,6 +17,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
class ServerEvents { class ServerEvents {
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>() private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
private openHandlers = new Set<() => void>()
private source: EventSource | null = null private source: EventSource | null = null
private retryDelay = RETRY_BASE_DELAY private retryDelay = RETRY_BASE_DELAY
@@ -28,10 +30,24 @@ class ServerEvents {
this.source.close() this.source.close()
} }
logSse("Connecting to backend events stream") logSse("Connecting to backend events stream")
this.source = serverApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect()) this.source = serverApi.connectEvents(
(event) => this.dispatch(event),
() => this.scheduleReconnect(),
(payload) => {
void serverApi
.sendClientConnectionPong({
...getClientIdentity(),
pingTs: payload.ts,
})
.catch((error) => {
log.error("Failed to send client connection pong", error)
})
},
)
this.source.onopen = () => { this.source.onopen = () => {
logSse("Events stream connected") logSse("Events stream connected")
this.retryDelay = RETRY_BASE_DELAY this.retryDelay = RETRY_BASE_DELAY
this.openHandlers.forEach((handler) => handler())
} }
} }
@@ -61,6 +77,11 @@ class ServerEvents {
bucket.add(handler) bucket.add(handler)
return () => bucket.delete(handler) return () => bucket.delete(handler)
} }
onOpen(handler: () => void): () => void {
this.openHandlers.add(handler)
return () => this.openHandlers.delete(handler)
}
} }
export const serverEvents = new ServerEvents() export const serverEvents = new ServerEvents()

View File

@@ -4,6 +4,7 @@ import { showToastNotification } from "../lib/notifications"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support" import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support"
import { serverEvents } from "../lib/server-events"
import { serverSettings } from "./preferences" import { serverSettings } from "./preferences"
import { loadSpeechCapabilities, speechCapabilities } from "./speech" import { loadSpeechCapabilities, speechCapabilities } from "./speech"
import { getActiveSession, sessions } from "./session-state" import { getActiveSession, sessions } from "./session-state"
@@ -30,6 +31,7 @@ interface PlaybackHandle {
const log = getLogger("actions") const log = getLogger("actions")
const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map()) const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map())
const LEADING_SPOKEN_BLOCK_REGEX = /^\s*```spoken[ \t]*\r?\n([\s\S]*?)\r?\n```(?:\r?\n|$)/i
const queuedKeys = new Set<string>() const queuedKeys = new Set<string>()
const spokenKeysBySession = new Map<string, Set<string>>() const spokenKeysBySession = new Map<string, Set<string>>()
@@ -43,6 +45,10 @@ let currentPlayback:
let queueRunner: Promise<void> | null = null let queueRunner: Promise<void> | null = null
let playbackErrorShown = false let playbackErrorShown = false
serverEvents.onOpen(() => {
void syncConversationModesToServer()
})
function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string { function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string {
return `${instanceId}:${sessionId}:${messageId}:${partId}` return `${instanceId}:${sessionId}:${messageId}:${partId}`
} }
@@ -107,6 +113,9 @@ export function canUseConversationMode(): boolean {
} }
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void { export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
const previous = isConversationModeEnabled(instanceId)
if (previous === enabled) return
setConversationModeInstances((prev) => { setConversationModeInstances((prev) => {
const next = new Map(prev) const next = new Map(prev)
if (enabled) { if (enabled) {
@@ -120,6 +129,23 @@ export function setConversationModeEnabled(instanceId: string, enabled: boolean)
if (!enabled) { if (!enabled) {
clearConversationPlaybackForInstance(instanceId) clearConversationPlaybackForInstance(instanceId)
} }
void serverApi.updateVoiceMode(instanceId, enabled).catch((error) => {
log.error("Failed to update conversation mode", error)
setConversationModeInstances((prev) => {
const next = new Map(prev)
if (previous) {
next.set(instanceId, true)
} else {
next.delete(instanceId)
}
return next
})
if (!previous) {
clearConversationPlaybackForInstance(instanceId)
}
})
} }
export function toggleConversationMode(instanceId: string): void { export function toggleConversationMode(instanceId: string): void {
@@ -188,7 +214,7 @@ export function handleConversationAssistantPartUpdated(instanceId: string, part:
if (!isConversationModeEnabled(instanceId)) return if (!isConversationModeEnabled(instanceId)) return
if (!isSpeakableSession(instanceId, sessionId)) return if (!isSpeakableSession(instanceId, sessionId)) return
const text = resolveTextPartContent(part).trim() const text = extractLeadingSpokenBlock(resolveTextPartContent(part))
if (!text) return if (!text) return
const key = getEntryKey(instanceId, sessionId, messageId, partId) const key = getEntryKey(instanceId, sessionId, messageId, partId)
@@ -505,3 +531,18 @@ function createObjectUrlFromBase64(audioBase64: string, mimeType: string): strin
} }
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" })) return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
} }
function extractLeadingSpokenBlock(text: string): string {
const match = text.match(LEADING_SPOKEN_BLOCK_REGEX)
if (!match?.[1]) return ""
return match[1].trim()
}
async function syncConversationModesToServer(): Promise<void> {
const updates: Promise<unknown>[] = []
for (const [instanceId, enabled] of conversationModeInstances()) {
if (!enabled) continue
updates.push(serverApi.updateVoiceMode(instanceId, true))
}
await Promise.allSettled(updates)
}