Compare commits

...

14 Commits

Author SHA1 Message Date
Shantur
995fb3b6a3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 19:35:28 +01:00
Shantur
aeb0ff11b3 fix(ui): stop conversation speech when voice input starts 2026-03-31 18:59:52 +01:00
Shantur
b61cfbd9f9 fix(ui): refine GitHub stars display 2026-03-31 18:51:53 +01:00
Shantur
481dd1a88a fix(ui): wrap long toast messages
Constrain toast titles and bodies so long retry and error messages wrap inside the notification card instead of overflowing past the container.
2026-03-31 18:41:32 +01:00
Shantur
3f6cdd36f3 feat(ui): surface retrying session status
Preserve retry metadata from session.status events so the session list and header can show a live retry countdown with context. Notify users when a session enters retry and reuse the existing error styling so retrying feels actionable without losing the current badge layout.
2026-03-31 18:38:54 +01:00
Shantur
fe932c8307 fix(ui): avoid caching incomplete code highlighting
Only cache markdown HTML after Shiki has the required fence languages loaded so virtualized assistant messages can re-render with syntax highlighting when remounted.
2026-03-31 15:18:44 +01:00
Pascal André
64ac885157 feat(ui): add session yolo mode controls (#256)
## Summary
- add a per-session Yolo mode toggle for permission prompts and persist
its state
- move the control into the Status tab with clearer copy, an info
tooltip, and a visible header badge when it is enabled
- auto-accept queued permissions for any yolo-enabled session in the
instance, not only the currently focused session

## Why
- keeps this risky mode explicit and easy to audit from the session
status area
- matches the expected multi-session desktop behavior when several
sessions stay active in parallel

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui

Closes #18
2026-03-31 14:46:20 +01:00
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
52 changed files with 1368 additions and 190 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

@@ -443,7 +443,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label={t("folderSelection.links.githubStars")} aria-label={t("folderSelection.links.githubStars")}
title={t("folderSelection.links.githubStars")} title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection") void openExternalUrl(GITHUB_URL, "folder-selection")

View File

@@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client"
import { loadBackgroundProcesses } from "../../stores/background-processes" import { loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
import SessionSidebar from "./shell/SessionSidebar" import SessionSidebar from "./shell/SessionSidebar"
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel" import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome" import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getSessionStatus } from "../../stores/session-status" import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid" import { Maximize2, ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types" import type { LayoutMode } from "./shell/types"
@@ -57,6 +57,13 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
import { useDrawerResize } from "./shell/useDrawerResize" import { useDrawerResize } from "./shell/useDrawerResize"
import { useSessionCache } from "./shell/useSessionCache" import { useSessionCache } from "./shell/useSessionCache"
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
import { getPermissionSessionId } from "../../types/permission"
import {
canAutoRespondPermission,
finishAutoRespondPermission,
getPermissionAutoAcceptInFlightVersion,
isPermissionAutoAcceptEnabled,
} from "../../stores/permission-auto-accept"
const log = getLogger("session") const log = getLogger("session")
@@ -97,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())
// Worktree selector manages its own dialogs. // Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false) const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -230,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
}) })
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => { const connectionStatusClass = () => {
const status = connectionStatus() const status = connectionStatus()
@@ -252,6 +266,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return permissions + questions > 0 return permissions + questions > 0
}) })
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
createEffect(() => {
getPermissionAutoAcceptInFlightVersion()
for (const permission of permissionQueue()) {
const sessionId = getPermissionSessionId(permission)
if (!sessionId) continue
if (!permission?.id) continue
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
.catch((error) => {
log.error("Failed to auto-accept permission", error)
})
.finally(() => {
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
})
}
})
const yoloModeEnabled = createMemo(() => {
const session = activeSessionForInstance()
if (!session) return false
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
})
const activeSessionStatusPill = createMemo(() => { const activeSessionStatusPill = createMemo(() => {
const activeSessionId = activeSessionIdForInstance() const activeSessionId = activeSessionIdForInstance()
if (!activeSessionId || activeSessionId === "info") return null if (!activeSessionId || activeSessionId === "info") return null
@@ -272,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
} }
const status = getSessionStatus(props.instance.id, activeSessionId) const status = getSessionStatus(props.instance.id, activeSessionId)
const text = const retry = getSessionRetry(props.instance.id, activeSessionId)
status === "working" const text = retry
? (() => {
const seconds = getRetrySeconds(retry.next, now())
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
})()
: status === "working"
? t("sessionList.status.working") ? t("sessionList.status.working")
: status === "compacting" : status === "compacting"
? t("sessionList.status.compacting") ? t("sessionList.status.compacting")
: t("sessionList.status.idle") : t("sessionList.status.idle")
return { return {
className: `session-${status}`, className: `session-${retry ? "retrying" : status}`,
text, text,
showAlertIcon: false, showAlertIcon: false,
title: retry
? t("sessionList.status.retryTooltip", {
message: retry.message,
attempt: String(retry.attempt),
})
: undefined,
} }
}) })
@@ -290,13 +342,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill() const pill = activeSessionStatusPill()
if (!pill) return null if (!pill) return null
return ( return (
<span class={`status-indicator session-status session-status-list ${pill.className}`}> <span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />} {pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text} {pill.text}
</span> </span>
) )
} }
const renderYoloModePill = () => {
if (!yoloModeEnabled()) return null
return (
<span
class="status-indicator session-status session-status-list session-yolo-mode"
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
title={t("instanceShell.yoloMode.badgeAriaLabel")}
>
<span class="status-dot" />
{t("instanceShell.yoloMode.badge")}
</span>
)
}
const renderSessionHeaderIndicators = () => (
<div class="flex items-center flex-wrap justify-center gap-2">
{renderYoloModePill()}
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div>
)
const handleCommandPaletteClick = () => { const handleCommandPaletteClick = () => {
showCommandPalette(props.instance.id) showCommandPalette(props.instance.id)
} }
@@ -622,12 +700,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<div class="flex-1 flex items-center justify-center min-w-0"> <div class="flex-1 flex items-center justify-center min-w-0">
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}> {renderSessionHeaderIndicators()}
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div> </div>
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center justify-center gap-1">
@@ -719,12 +792,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<div class="ml-auto flex items-center session-header-hints"> <div class="ml-auto flex items-center session-header-hints">
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}> {renderSessionHeaderIndicators()}
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div> </div>
</div> </div>

View File

@@ -48,104 +48,103 @@ interface SessionSidebarProps {
} }
const SessionSidebar: Component<SessionSidebarProps> = (props) => ( const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}> <div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base"> <div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary"> <span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")} {props.t("instanceShell.leftPanel.sessionsTitle")}
</span> </span>
<div class="flex items-center gap-2 text-primary"> <div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
>
<PlusSquare class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
<Search class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")} aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())} title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
> >
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} <PlusSquare class="w-5 h-5" />
</IconButton> </IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")} aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("instanceShell.leftDrawer.toggle.close")} title={props.t("sessionList.filter.ariaLabel")}
onClick={props.onCloseLeftDrawer} aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
> >
<MenuOpenIcon fontSize="small" /> <Search class="w-5 h-5" />
</IconButton> </IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show> </Show>
</div> </div>
</div> </div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0"> <div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList <SessionList
instanceId={props.instanceId} instanceId={props.instanceId}
threads={props.threads()} threads={props.threads()}
activeSessionId={props.activeSessionId()} activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession} onSelect={props.onSelectSession}
onNew={() => { onNew={() => {
const result = props.onNewSession() const result = props.onNewSession()
if (result instanceof Promise) { if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error)) void result.catch((error) => log.error("Failed to create session:", error))
} }
}} }}
enableFilterBar={props.showSearch()} enableFilterBar={props.showSearch()}
showHeader={false} showHeader={false}
showFooter={false} showFooter={false}
/> />
<div class="session-sidebar-separator" /> <div class="session-sidebar-separator" />
<Show when={props.activeSession()}> <Show when={props.activeSession()}>
{(activeSession) => ( {(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3"> <div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} /> <WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
@@ -177,11 +176,10 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
showDescription={false} showDescription={false}
/> />
</div> </div>
</> )}
)} </Show>
</Show> </div>
</div> </div>
</div> )
)
export default SessionSidebar export default SessionSidebar

View File

@@ -89,6 +89,7 @@ interface RightPanelProps {
const RightPanel: Component<RightPanelProps> = (props) => { const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes")) const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([ const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"yolo-mode",
"plan", "plan",
"background-processes", "background-processes",
"mcp", "mcp",
@@ -787,7 +788,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setRightPanelTab("changes") setRightPanelTab("changes")
} }
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"] const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
createEffect(() => { createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems()) const currentExpanded = new Set(rightPanelExpandedItems())

View File

@@ -2,6 +2,7 @@ import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip" import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
@@ -12,6 +13,7 @@ import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel" import ContextUsagePanel from "../../../../session/context-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo" import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status" import InstanceServiceStatus from "../../../../instance-service-status"
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
interface StatusTabProps { interface StatusTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -39,6 +41,35 @@ interface StatusTabProps {
const StatusTab: Component<StatusTabProps> = (props) => { const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id) const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
const renderYoloModeSection = () => {
const session = props.activeSession()
if (!session) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
</div>
)
}
return (
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
</div>
<Switch
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
color="warning"
size="small"
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
/>
</div>
</div>
)
}
const renderStatusSessionChanges = () => { const renderStatusSessionChanges = () => {
const sessionId = props.activeSessionId() const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") { if (!sessionId || sessionId === "info") {
@@ -204,6 +235,12 @@ const StatusTab: Component<StatusTabProps> = (props) => {
} }
const statusSections = [ const statusSections = [
{
id: "yolo-mode",
labelKey: "instanceShell.rightPanel.sections.yoloMode",
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
render: renderYoloModeSection,
},
{ {
id: "session-changes", id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges", labelKey: "instanceShell.rightPanel.sections.sessionChanges",
@@ -281,29 +318,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<For each={statusSections}> <For each={statusSections}>
{(section) => ( {(section) => (
<Accordion.Item value={section.id} class="right-panel-accordion-item"> <Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header> <Accordion.Header class="right-panel-accordion-header-row">
<Accordion.Trigger class="right-panel-accordion-trigger"> <Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left"> <span class="section-left">
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger
class="section-info-trigger"
aria-label={props.t(section.tooltipKey)}
onClick={(e) => e.stopPropagation()}
>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">
{props.t(section.tooltipKey)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
<span class="section-label">{props.t(section.labelKey)}</span> <span class="section-label">{props.t(section.labelKey)}</span>
</span> </span>
<ChevronDown <ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`} class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/> />
</Accordion.Trigger> </Accordion.Trigger>
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content> <Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item> </Accordion.Item>

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,20 +118,26 @@ 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,
}) })
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => { const commitCacheEntry = (
snapshot: ReturnType<typeof resolved>,
renderedHtml: string,
options?: { cache?: boolean },
) => {
const cacheEntry: RenderCache = { const cacheEntry: RenderCache = {
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) if (options?.cache ?? true) {
cacheHandle.set(cacheEntry)
}
notifyRendered() notifyRendered()
} }
@@ -138,20 +146,23 @@ 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,
}) })
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
if (latestRequestKey === snapshot.requestKey) { if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, rendered) commitCacheEntry(snapshot, rendered, { cache: shouldCache })
} }
} }
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

@@ -19,7 +19,12 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput" import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech" import {
canUseConversationMode,
clearConversationPlaybackForInstance,
isConversationModeEnabled,
toggleConversationMode,
} from "../stores/conversation-speech"
const log = getLogger("actions") const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker")) const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
@@ -492,6 +497,8 @@ export default function PromptInput(props: PromptInputProps) {
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => { const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true voiceButtonPressed = true
// Treat a mic press as barge-in: stop any active assistant speech before listening.
clearConversationPlaybackForInstance(props.instanceId)
if (event instanceof PointerEvent) { if (event instanceof PointerEvent) {
const target = event.currentTarget const target = event.currentTarget

View File

@@ -1,8 +1,8 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js" import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
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 { getRetrySeconds, getSessionRetry, 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,14 @@ 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 [now, setNow] = createSignal(Date.now())
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
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 +222,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)
} }
@@ -372,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const isActive = () => props.activeSessionId === rowProps.sessionId const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || t("sessionList.session.untitled") const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
const statusLabel = () => { const statusLabel = () => {
const retryState = retry()
if (retryState) {
const seconds = getRetrySeconds(retryState.next, now())
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
}
switch (formatSessionStatus(status())) { switch (formatSessionStatus(status())) {
case "working": case "working":
return t("sessionList.status.working") return t("sessionList.status.working")
@@ -385,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
const needsPermission = () => Boolean(session()?.pendingPermission) const needsPermission = () => Boolean(session()?.pendingPermission)
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion() const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`) const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
const statusText = () => const statusText = () =>
needsPermission() needsPermission()
? t("sessionList.status.needsPermission") ? t("sessionList.status.needsPermission")
: needsQuestion() : needsQuestion()
? t("sessionList.status.needsInput") ? t("sessionList.status.needsInput")
: statusLabel() : statusLabel()
const statusTooltip = () => {
const retryState = retry()
if (!retryState) return undefined
return t("sessionList.status.retryTooltip", {
message: retryState.message,
attempt: String(retryState.attempt),
})
}
const isSelected = () => selectedSessionIds().has(rowProps.sessionId) const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
@@ -471,7 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} /> <ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span> </span>
</Show> </Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}> <span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />} {needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()} {statusText()}
</span> </span>
@@ -493,6 +542,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

@@ -19,9 +19,6 @@ export function formatCompactCount(value: number): string {
return `${(value / 1_000_000).toFixed(1)}M` return `${(value / 1_000_000).toFixed(1)}M`
} }
if (value >= 10_000) { if (value >= 10_000) {
return `${Math.round(value / 1_000)}K`
}
if (value >= 1_000) {
const label = `${(value / 1_000).toFixed(1)}K` const label = `${(value / 1_000).toFixed(1)}K`
return label.replace(/\.0K$/, "K") return label.replace(/\.0K$/, "K")
} }

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sessions", "instanceShell.leftPanel.sessionsTitle": "Sessions",
"instanceShell.leftPanel.instanceInfo": "Instance Info", "instanceShell.leftPanel.instanceInfo": "Instance Info",
"instanceShell.leftDrawer.pin": "Pin left drawer", "instanceShell.leftDrawer.pin": "Pin left drawer",
"instanceShell.leftDrawer.unpin": "Unpin left drawer", "instanceShell.leftDrawer.unpin": "Unpin left drawer",
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned", "instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully", "instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
"instanceShell.rightPanel.toast.saveError": "Failed to save file", "instanceShell.rightPanel.toast.saveError": "Failed to save file",
"instanceShell.rightPanel.sections.yoloMode": "Yolo Mode",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Automatically approves permission requests for the current session. Use it only when you trust the tools being run.",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -150,6 +151,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Select a session to view plan.", "instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.", "instanceShell.plan.empty": "Nothing planned yet.",
"instanceShell.yoloMode.noSessionSelected": "Select a session to configure Yolo mode.",
"instanceShell.yoloMode.title": "Yolo mode",
"instanceShell.yoloMode.description": "Automatically approve permission requests for this session. Disabled by default.",
"instanceShell.yoloMode.badge": "Yolo mode",
"instanceShell.yoloMode.badgeAriaLabel": "Yolo mode enabled",
"instanceShell.backgroundProcesses.empty": "No background processes.", "instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}", "instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Working", "sessionList.status.working": "Working",
"sessionList.status.compacting": "Compacting", "sessionList.status.compacting": "Compacting",
"sessionList.status.idle": "Idle", "sessionList.status.idle": "Idle",
"sessionList.status.retrying": "Retrying",
"sessionList.status.retryingIn": "Retrying in {seconds}s",
"sessionList.status.retryTooltip": "{message} (Attempt {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Attempt {attempt})",
"sessionList.status.needsPermission": "Needs Permission", "sessionList.status.needsPermission": "Needs Permission",
"sessionList.status.needsInput": "Needs Input", "sessionList.status.needsInput": "Needs Input",
"sessionList.expand.collapseAriaLabel": "Collapse session", "sessionList.expand.collapseAriaLabel": "Collapse session",
@@ -25,12 +29,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

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sesiones", "instanceShell.leftPanel.sessionsTitle": "Sesiones",
"instanceShell.leftPanel.instanceInfo": "Info de la instancia", "instanceShell.leftPanel.instanceInfo": "Info de la instancia",
"instanceShell.leftDrawer.pin": "Fijar panel izquierdo", "instanceShell.leftDrawer.pin": "Fijar panel izquierdo",
"instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo", "instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo",
"instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado", "instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente", "instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo", "instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
"instanceShell.rightPanel.sections.yoloMode": "Modo yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Aprueba automaticamente las solicitudes de permiso de la sesion actual. Usalo solo si confias en las herramientas que se estan ejecutando.",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.", "instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.", "instanceShell.plan.empty": "Aún no hay nada planificado.",
"instanceShell.yoloMode.noSessionSelected": "Selecciona una sesion para configurar el modo yolo.",
"instanceShell.yoloMode.title": "Modo yolo",
"instanceShell.yoloMode.description": "Aprueba automaticamente las solicitudes de permiso de esta sesion. Esta desactivado por defecto.",
"instanceShell.yoloMode.badge": "Modo yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Modo yolo activado",
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.", "instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}", "instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB", "instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Trabajando", "sessionList.status.working": "Trabajando",
"sessionList.status.compacting": "Compactando", "sessionList.status.compacting": "Compactando",
"sessionList.status.idle": "Inactiva", "sessionList.status.idle": "Inactiva",
"sessionList.status.retrying": "Reintentando",
"sessionList.status.retryingIn": "Reintentando en {seconds}s",
"sessionList.status.retryTooltip": "{message} (Intento {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Intento {attempt})",
"sessionList.status.needsPermission": "Requiere permiso", "sessionList.status.needsPermission": "Requiere permiso",
"sessionList.status.needsInput": "Requiere entrada", "sessionList.status.needsInput": "Requiere entrada",
"sessionList.expand.collapseAriaLabel": "Colapsar sesión", "sessionList.expand.collapseAriaLabel": "Colapsar sesión",
@@ -25,12 +29,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

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sessions", "instanceShell.leftPanel.sessionsTitle": "Sessions",
"instanceShell.leftPanel.instanceInfo": "Infos de l'instance", "instanceShell.leftPanel.instanceInfo": "Infos de l'instance",
"instanceShell.leftDrawer.pin": "Épingler le tiroir gauche", "instanceShell.leftDrawer.pin": "Épingler le tiroir gauche",
"instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche", "instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche",
"instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé", "instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès", "instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier", "instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
"instanceShell.rightPanel.sections.yoloMode": "Mode yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Approuve automatiquement les demandes d'autorisation pour la session actuelle. A utiliser seulement si vous faites confiance aux outils executes.",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session", "instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.", "instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.", "instanceShell.plan.empty": "Aucun plan pour l'instant.",
"instanceShell.yoloMode.noSessionSelected": "Selectionnez une session pour configurer le mode yolo.",
"instanceShell.yoloMode.title": "Mode yolo",
"instanceShell.yoloMode.description": "Approuve automatiquement les demandes d'autorisation pour cette session. Desactive par defaut.",
"instanceShell.yoloMode.badge": "Mode yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Mode yolo active",
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.", "instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
"instanceShell.backgroundProcesses.status": "Statut : {status}", "instanceShell.backgroundProcesses.status": "Statut : {status}",
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "En cours", "sessionList.status.working": "En cours",
"sessionList.status.compacting": "Compactage", "sessionList.status.compacting": "Compactage",
"sessionList.status.idle": "Inactif", "sessionList.status.idle": "Inactif",
"sessionList.status.retrying": "Nouvelle tentative",
"sessionList.status.retryingIn": "Nouvelle tentative dans {seconds}s",
"sessionList.status.retryTooltip": "{message} (Tentative {attempt})",
"sessionList.status.retryToast": "{countdown} : {message} (Tentative {attempt})",
"sessionList.status.needsPermission": "Autorisation requise", "sessionList.status.needsPermission": "Autorisation requise",
"sessionList.status.needsInput": "Entrée requise", "sessionList.status.needsInput": "Entrée requise",
"sessionList.expand.collapseAriaLabel": "Réduire la session", "sessionList.expand.collapseAriaLabel": "Réduire la session",
@@ -25,12 +29,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

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "סשנים", "instanceShell.leftPanel.sessionsTitle": "סשנים",
"instanceShell.leftPanel.instanceInfo": "מידע על המופע", "instanceShell.leftPanel.instanceInfo": "מידע על המופע",
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית", "instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית", "instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה", "instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה", "instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ", "instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
"instanceShell.rightPanel.sections.yoloMode": "מצב Yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "מאשר אוטומטית בקשות הרשאה עבור הסשן הנוכחי. השתמשו בזה רק אם אתם סומכים על הכלים שרצים.",
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן", "instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
"instanceShell.rightPanel.sections.plan": "תוכנית", "instanceShell.rightPanel.sections.plan": "תוכנית",
@@ -148,6 +149,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.", "instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.", "instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
"instanceShell.yoloMode.noSessionSelected": "בחרו סשן כדי להגדיר מצב Yolo.",
"instanceShell.yoloMode.title": "מצב Yolo",
"instanceShell.yoloMode.description": "מאשר אוטומטית בקשות הרשאה עבור הסשן הזה. כבוי כברירת מחדל.",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "מצב Yolo פעיל",
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.", "instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}", "instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "עובד", "sessionList.status.working": "עובד",
"sessionList.status.compacting": "מסכם", "sessionList.status.compacting": "מסכם",
"sessionList.status.idle": "מוכן", "sessionList.status.idle": "מוכן",
"sessionList.status.retrying": "מנסה שוב",
"sessionList.status.retryingIn": "מנסה שוב בעוד {seconds}ש׳",
"sessionList.status.retryTooltip": "{message} (ניסיון {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (ניסיון {attempt})",
"sessionList.status.needsPermission": "נדרש אישור", "sessionList.status.needsPermission": "נדרש אישור",
"sessionList.status.needsInput": "נדרש קלט", "sessionList.status.needsInput": "נדרש קלט",
"sessionList.expand.collapseAriaLabel": "כווץ סשן", "sessionList.expand.collapseAriaLabel": "כווץ סשן",
@@ -25,12 +29,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

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "セッション", "instanceShell.leftPanel.sessionsTitle": "セッション",
"instanceShell.leftPanel.instanceInfo": "インスタンス情報", "instanceShell.leftPanel.instanceInfo": "インスタンス情報",
"instanceShell.leftDrawer.pin": "左ドロワーを固定", "instanceShell.leftDrawer.pin": "左ドロワーを固定",
"instanceShell.leftDrawer.unpin": "左ドロワーの固定を解除", "instanceShell.leftDrawer.unpin": "左ドロワーの固定を解除",
"instanceShell.leftDrawer.toggle.pinned": "左ドロワーを固定しました", "instanceShell.leftDrawer.toggle.pinned": "左ドロワーを固定しました",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "キャンセル", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "キャンセル",
"instanceShell.rightPanel.toast.saveSuccess": "ファイルを保存しました", "instanceShell.rightPanel.toast.saveSuccess": "ファイルを保存しました",
"instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました", "instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました",
"instanceShell.rightPanel.sections.yoloMode": "Yoloモード",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "現在のセッションの権限リクエストを自動承認します。実行中のツールを信頼できる場合にのみ使用してください。",
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更", "instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
"instanceShell.rightPanel.sections.plan": "計画", "instanceShell.rightPanel.sections.plan": "計画",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。", "instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。", "instanceShell.plan.empty": "まだ計画はありません。",
"instanceShell.yoloMode.noSessionSelected": "Yoloモードを設定するにはセッションを選択してください。",
"instanceShell.yoloMode.title": "Yoloモード",
"instanceShell.yoloMode.description": "このセッションの権限リクエストを自動承認します。デフォルトでは無効です。",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Yoloモードが有効",
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。", "instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
"instanceShell.backgroundProcesses.status": "状態: {status}", "instanceShell.backgroundProcesses.status": "状態: {status}",
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "作業中", "sessionList.status.working": "作業中",
"sessionList.status.compacting": "圧縮中", "sessionList.status.compacting": "圧縮中",
"sessionList.status.idle": "待機中", "sessionList.status.idle": "待機中",
"sessionList.status.retrying": "再試行中",
"sessionList.status.retryingIn": "{seconds}秒後に再試行",
"sessionList.status.retryTooltip": "{message}{attempt}回目)",
"sessionList.status.retryToast": "{countdown}: {message}{attempt}回目)",
"sessionList.status.needsPermission": "許可待ち", "sessionList.status.needsPermission": "許可待ち",
"sessionList.status.needsInput": "入力待ち", "sessionList.status.needsInput": "入力待ち",
"sessionList.expand.collapseAriaLabel": "セッションを折りたたむ", "sessionList.expand.collapseAriaLabel": "セッションを折りたたむ",
@@ -25,12 +29,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

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Сессии", "instanceShell.leftPanel.sessionsTitle": "Сессии",
"instanceShell.leftPanel.instanceInfo": "Информация об экземпляре", "instanceShell.leftPanel.instanceInfo": "Информация об экземпляре",
"instanceShell.leftDrawer.pin": "Закрепить левую панель", "instanceShell.leftDrawer.pin": "Закрепить левую панель",
"instanceShell.leftDrawer.unpin": "Открепить левую панель", "instanceShell.leftDrawer.unpin": "Открепить левую панель",
"instanceShell.leftDrawer.toggle.pinned": "Левая панель закреплена", "instanceShell.leftDrawer.toggle.pinned": "Левая панель закреплена",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Отмена", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Отмена",
"instanceShell.rightPanel.toast.saveSuccess": "Файл успешно сохранён", "instanceShell.rightPanel.toast.saveSuccess": "Файл успешно сохранён",
"instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл", "instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл",
"instanceShell.rightPanel.sections.yoloMode": "Режим Yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Автоматически одобряет запросы разрешений для текущей сессии. Включайте только если доверяете запускаемым инструментам.",
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
"instanceShell.rightPanel.sections.plan": "План", "instanceShell.rightPanel.sections.plan": "План",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.", "instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
"instanceShell.plan.empty": "Пока ничего не запланировано.", "instanceShell.plan.empty": "Пока ничего не запланировано.",
"instanceShell.yoloMode.noSessionSelected": "Выберите сессию, чтобы настроить режим Yolo.",
"instanceShell.yoloMode.title": "Режим Yolo",
"instanceShell.yoloMode.description": "Автоматически одобряет запросы разрешений для этой сессии. По умолчанию выключен.",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Режим Yolo включен",
"instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.", "instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.",
"instanceShell.backgroundProcesses.status": "Статус: {status}", "instanceShell.backgroundProcesses.status": "Статус: {status}",
"instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Работает", "sessionList.status.working": "Работает",
"sessionList.status.compacting": "Компактация", "sessionList.status.compacting": "Компактация",
"sessionList.status.idle": "Простой", "sessionList.status.idle": "Простой",
"sessionList.status.retrying": "Повтор",
"sessionList.status.retryingIn": "Повтор через {seconds}с",
"sessionList.status.retryTooltip": "{message} (Попытка {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Попытка {attempt})",
"sessionList.status.needsPermission": "Требуется разрешение", "sessionList.status.needsPermission": "Требуется разрешение",
"sessionList.status.needsInput": "Требуется ввод", "sessionList.status.needsInput": "Требуется ввод",
"sessionList.expand.collapseAriaLabel": "Свернуть сессию", "sessionList.expand.collapseAriaLabel": "Свернуть сессию",
@@ -25,12 +29,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

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "会话", "instanceShell.leftPanel.sessionsTitle": "会话",
"instanceShell.leftPanel.instanceInfo": "实例信息", "instanceShell.leftPanel.instanceInfo": "实例信息",
"instanceShell.leftDrawer.pin": "固定左侧抽屉", "instanceShell.leftDrawer.pin": "固定左侧抽屉",
"instanceShell.leftDrawer.unpin": "取消固定左侧抽屉", "instanceShell.leftDrawer.unpin": "取消固定左侧抽屉",
"instanceShell.leftDrawer.toggle.pinned": "左侧抽屉已固定", "instanceShell.leftDrawer.toggle.pinned": "左侧抽屉已固定",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "取消", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "取消",
"instanceShell.rightPanel.toast.saveSuccess": "文件保存成功", "instanceShell.rightPanel.toast.saveSuccess": "文件保存成功",
"instanceShell.rightPanel.toast.saveError": "保存文件失败", "instanceShell.rightPanel.toast.saveError": "保存文件失败",
"instanceShell.rightPanel.sections.yoloMode": "Yolo 模式",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "自动批准当前会话的权限请求。仅在你信任正在运行的工具时启用。",
"instanceShell.rightPanel.sections.sessionChanges": "会话更改", "instanceShell.rightPanel.sections.sessionChanges": "会话更改",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
"instanceShell.rightPanel.sections.plan": "计划", "instanceShell.rightPanel.sections.plan": "计划",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。", "instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
"instanceShell.plan.empty": "暂无计划。", "instanceShell.plan.empty": "暂无计划。",
"instanceShell.yoloMode.noSessionSelected": "请选择一个会话来配置 Yolo 模式。",
"instanceShell.yoloMode.title": "Yolo 模式",
"instanceShell.yoloMode.description": "自动批准此会话的权限请求。默认关闭。",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Yolo 模式已启用",
"instanceShell.backgroundProcesses.empty": "没有后台进程。", "instanceShell.backgroundProcesses.empty": "没有后台进程。",
"instanceShell.backgroundProcesses.status": "状态:{status}", "instanceShell.backgroundProcesses.status": "状态:{status}",
"instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB", "instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "工作中", "sessionList.status.working": "工作中",
"sessionList.status.compacting": "压缩中", "sessionList.status.compacting": "压缩中",
"sessionList.status.idle": "空闲", "sessionList.status.idle": "空闲",
"sessionList.status.retrying": "重试中",
"sessionList.status.retryingIn": "{seconds} 秒后重试",
"sessionList.status.retryTooltip": "{message}(第 {attempt} 次尝试)",
"sessionList.status.retryToast": "{countdown}: {message}(第 {attempt} 次尝试)",
"sessionList.status.needsPermission": "需要权限", "sessionList.status.needsPermission": "需要权限",
"sessionList.status.needsInput": "需要输入", "sessionList.status.needsInput": "需要输入",
"sessionList.expand.collapseAriaLabel": "折叠会话", "sessionList.expand.collapseAriaLabel": "折叠会话",
@@ -25,12 +29,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
@@ -119,14 +120,7 @@ function resolveLanguage(token: string): { canonical: string | null; raw: string
return { canonical: null, raw: normalized } return { canonical: null, raw: normalized }
} }
async function ensureLanguages(content: string) { function collectCodeFenceLanguages(content: string): string[] {
if (highlightSuppressed) {
return
}
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
// to miss these and prevent languages from loading.
const foundLanguages = new Set<string>() const foundLanguages = new Set<string>()
try { try {
const tokens = marked.lexer(content) as any const tokens = marked.lexer(content) as any
@@ -138,10 +132,44 @@ async function ensureLanguages(content: string) {
} }
}) })
} catch { } catch {
// If tokenization fails for any reason, skip language preloading. return []
}
return [...foundLanguages]
}
export function hasPendingCodeHighlight(content: string): boolean {
const languages = collectCodeFenceLanguages(content)
for (const token of languages) {
const rawToken = normalizeLanguageToken(token)
if (!rawToken || rawToken === "text") {
continue
}
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
if (langKey === "text" || raw === "text") {
continue
}
if (!highlighter || !loadedLanguages.has(langKey)) {
return true
}
}
return false
}
async function ensureLanguages(content: string) {
if (highlightSuppressed) {
return return
} }
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
// to miss these and prevent languages from loading.
const foundLanguages = collectCodeFenceLanguages(content)
// Queue language loading tasks // Queue language loading tasks
for (const token of foundLanguages) { for (const token of foundLanguages) {
const rawToken = normalizeLanguageToken(token) const rawToken = normalizeLanguageToken(token)
@@ -285,6 +313,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 +344,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 +353,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 +362,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

@@ -102,9 +102,11 @@ export function showToastNotification(payload: ToastPayload): ToastHandle {
</button> </button>
<div class="flex items-start gap-3 pr-6"> <div class="flex items-start gap-3 pr-6">
<span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} /> <span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} />
<div class="flex-1 text-sm leading-snug"> <div class="min-w-0 flex-1 text-sm leading-snug">
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>} {payload.title && <p class={`break-words ${accent.headline} font-semibold`}>{payload.title}</p>}
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p> <p class={`${accent.body} ${payload.title ? "mt-1" : ""} whitespace-pre-wrap break-words [overflow-wrap:anywhere]`}>
{payload.message}
</p>
{payload.action && ( {payload.action && (
<button <button
type="button" type="button"

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

View File

@@ -0,0 +1,81 @@
import { createSignal } from "solid-js"
const STORAGE_KEY = "codenomad:permission-auto-accept:v1"
function makeKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
function readInitialState() {
if (typeof window === "undefined" || !window.localStorage) {
return new Map<string, boolean>()
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return new Map<string, boolean>()
const parsed = JSON.parse(raw) as Record<string, boolean>
return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true))
} catch {
return new Map<string, boolean>()
}
}
function persist(next: Map<string, boolean>) {
if (typeof window === "undefined" || !window.localStorage) {
return
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(next)))
} catch {
// ignore persistence failures
}
}
const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState())
const [inFlightVersion, setInFlightVersion] = createSignal(0)
const inFlight = new Set<string>()
export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) {
return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false
}
export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: string, enabled: boolean) {
const key = makeKey(instanceId, sessionId)
setAutoAcceptState((prev) => {
const next = new Map(prev)
if (enabled) {
next.set(key, true)
} else {
next.delete(key)
}
persist(next)
return next
})
}
export function togglePermissionAutoAccept(instanceId: string, sessionId: string) {
setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId))
}
export function canAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
const key = makeKey(instanceId, sessionId)
if (!autoAcceptState().get(key)) return false
const requestKey = `${key}:${requestId}`
if (inFlight.has(requestKey)) return false
inFlight.add(requestKey)
return true
}
export function getPermissionAutoAcceptInFlightVersion() {
return inFlightVersion()
}
export function finishAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
if (!inFlight.delete(`${makeKey(instanceId, sessionId)}:${requestId}`)) {
return
}
setInFlightVersion((value) => value + 1)
}

View File

@@ -1,4 +1,4 @@
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import { mapSdkSessionRetry, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import type { Message } from "../types/message" import type { Message } from "../types/message"
import type { FileDiff } from "@opencode-ai/sdk/v2/client" import type { FileDiff } from "@opencode-ai/sdk/v2/client"
@@ -149,12 +149,15 @@ async function fetchSessions(instanceId: string): Promise<void> {
const existingStatus = existingSession?.status const existingStatus = existingSession?.status
let status: SessionStatus let status: SessionStatus
let retry = existingSession?.retry ?? null
if (existingStatus === "compacting") { if (existingStatus === "compacting") {
status = "compacting" status = "compacting"
retry = null
} else { } else {
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id] const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle" status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
retry = hasType ? mapSdkSessionRetry(rawStatus) : retry
} }
sessionMap.set(apiSession.id, { sessionMap.set(apiSession.id, {
@@ -165,6 +168,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
agent: existingSession?.agent ?? "", agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" }, model: existingSession?.model ?? { providerId: "", modelId: "" },
status, status,
retry,
version: apiSession.version, version: apiSession.version,
time: { time: {
...apiSession.time, ...apiSession.time,

View File

@@ -28,7 +28,7 @@ import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question" import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
import type { QuestionRequest } from "../types/question" import type { QuestionRequest } from "../types/question"
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2" import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
import { showToastNotification, ToastVariant } from "../lib/notifications" import { showToastNotification, type ToastHandle, ToastVariant } from "../lib/notifications"
import { sendOsNotification } from "../lib/os-notifications" import { sendOsNotification } from "../lib/os-notifications"
import { preferences } from "./preferences" import { preferences } from "./preferences"
import { import {
@@ -39,7 +39,14 @@ import {
removeQuestionFromQueue, removeQuestionFromQueue,
} from "./instances" } from "./instances"
import { showAlertDialog } from "./alerts" import { showAlertDialog } from "./alerts"
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import {
createClientSession,
mapSdkSessionRetry,
mapSdkSessionStatus,
type Session,
type SessionRetryState,
type SessionStatus,
} from "../types/session"
import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
@@ -67,6 +74,15 @@ import { handleConversationAssistantPartUpdated } from "./conversation-speech"
const log = getLogger("sse") const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>() const pendingSessionFetches = new Map<string, Promise<void>>()
let activeRetryToast: ToastHandle | null = null
function isSameRetryState(left: SessionRetryState | null | undefined, right: SessionRetryState | null | undefined): boolean {
const a = left ?? null
const b = right ?? null
if (a === b) return true
if (!a || !b) return false
return a.attempt === b.attempt && a.message === b.message && a.next === b.next
}
function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean { function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
if (typeof document === "undefined") return false if (typeof document === "undefined") return false
@@ -131,18 +147,20 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"]) const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, retry?: SessionRetryState | null) {
let parentToExpand: string | null = null let parentToExpand: string | null = null
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
const current = session.status ?? "idle" const current = session.status ?? "idle"
if (current === status) return false const nextRetry = retry ?? null
if (current === status && isSameRetryState(session.retry, nextRetry)) return false
if (current === "compacting" && status !== "compacting") { if (current === "compacting" && status !== "compacting") {
return false return false
} }
session.status = status session.status = status
session.retry = status === "working" ? nextRetry : null
// Auto-expand the parent thread when a child session starts working. // Auto-expand the parent thread when a child session starts working.
// Users can still collapse it; we only expand on the transition. // Users can still collapse it; we only expand on the transition.
@@ -172,6 +190,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
) )
let fetchedStatus: SessionStatus = "idle" let fetchedStatus: SessionStatus = "idle"
let fetchedRetry: SessionRetryState | null = null
try { try {
let statuses: Record<string, any> = {} let statuses: Record<string, any> = {}
try { try {
@@ -187,11 +206,13 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
const rawStatus = (info as any)?.status ?? statuses?.[sessionId] const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle" fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
fetchedRetry = hasType ? mapSdkSessionRetry(rawStatus) : null
} catch (error) { } catch (error) {
log.error("Failed to fetch session status", error) log.error("Failed to fetch session status", error)
} }
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
fetched.retry = fetchedRetry
let updatedInstanceSessions: Map<string, Session> | undefined let updatedInstanceSessions: Map<string, Session> | undefined
let shouldExpandParent: string | null = null let shouldExpandParent: string | null = null
@@ -205,6 +226,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
agent: existing?.agent ?? fetched.agent, agent: existing?.agent ?? fetched.agent,
model: existing?.model ?? fetched.model, model: existing?.model ?? fetched.model,
status: existing?.status === "compacting" ? "compacting" : fetched.status, status: existing?.status === "compacting" ? "compacting" : fetched.status,
retry: existing?.status === "compacting" ? null : fetched.retry,
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
pendingQuestion: existing?.pendingQuestion ?? false, pendingQuestion: existing?.pendingQuestion ?? false,
} }
@@ -231,14 +253,20 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
} }
} }
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) { function ensureSessionStatus(
instanceId: string,
sessionId: string,
status: SessionStatus,
directory?: string,
retry?: SessionRetryState | null,
) {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId) const existing = instanceSessions?.get(sessionId)
if (existing) { if (existing) {
if ((existing.status ?? "idle") === status) { if ((existing.status ?? "idle") === status && isSameRetryState(existing.retry, retry)) {
return return
} }
applySessionStatus(instanceId, sessionId, status) applySessionStatus(instanceId, sessionId, status, retry)
return return
} }
@@ -250,7 +278,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
const pending = (async () => { const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId, directory) const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
if (!fetched) return if (!fetched) return
applySessionStatus(instanceId, sessionId, status) applySessionStatus(instanceId, sessionId, status, retry)
})() })()
pendingSessionFetches.set(key, pending) pendingSessionFetches.set(key, pending)
@@ -428,6 +456,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
modelId: "", modelId: "",
}, },
status: "idle", status: "idle",
retry: null,
version: info.version || "0", version: info.version || "0",
time: info.time time: info.time
? { ...info.time } ? { ...info.time }
@@ -461,6 +490,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
...existingSession, ...existingSession,
title: info.title || existingSession.title, title: info.title || existingSession.title,
status: existingSession.status ?? "idle", status: existingSession.status ?? "idle",
retry: existingSession.retry ?? null,
time: mergedTime, time: mergedTime,
revert: info.revert revert: info.revert
? { ? {
@@ -532,8 +562,29 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status) const rawStatus = event.properties.status
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory) const status = mapSdkSessionStatus(rawStatus)
const retry = mapSdkSessionRetry(rawStatus)
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory, retry)
if (retry) {
const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000))
const countdown =
remainingSeconds > 0
? tGlobal("sessionList.status.retryingIn", { seconds: String(remainingSeconds) })
: tGlobal("sessionList.status.retrying")
const label = getSessionTitle(instanceId, sessionId)
activeRetryToast?.dismiss()
activeRetryToast = showToastNotification({
title: label || getInstanceDisplayName(instanceId),
message: tGlobal("sessionList.status.retryToast", {
countdown,
message: retry.message,
attempt: String(retry.attempt),
}),
variant: "error",
duration: 7000,
})
}
log.info(`[SSE] Session status updated: ${sessionId}`, { status }) log.info(`[SSE] Session status updated: ${sessionId}`, { status })
} }
@@ -547,6 +598,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
if (existing) { if (existing) {
withSession(instanceId, sessionID, (session) => { withSession(instanceId, sessionID, (session) => {
session.status = "working" session.status = "working"
session.retry = null
}) })
} else { } else {
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory) ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)

View File

@@ -353,6 +353,9 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session
if (session.status === status) return false if (session.status === status) return false
const previous = session.status const previous = session.status
session.status = status session.status = status
if (status !== "working") {
session.retry = null
}
// If a child session starts working, auto-expand its parent thread once. // If a child session starts working, auto-expand its parent thread once.
// Users can still collapse it afterwards; we only expand on the transition. // Users can still collapse it afterwards; we only expand on the transition.

View File

@@ -1,4 +1,4 @@
import type { Session, SessionStatus } from "../types/session" import type { Session, SessionRetryState, SessionStatus } from "../types/session"
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state" import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
function getSession(instanceId: string, sessionId: string): Session | null { function getSession(instanceId: string, sessionId: string): Session | null {
@@ -14,6 +14,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
return session.status ?? "idle" return session.status ?? "idle"
} }
export function getSessionRetry(instanceId: string, sessionId: string): SessionRetryState | null {
const session = getSession(instanceId, sessionId)
return session?.retry ?? null
}
export function getRetrySeconds(next: number, now = Date.now()): number {
return Math.max(0, Math.round((next - now) / 1000))
}
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus { export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {

View File

@@ -184,6 +184,7 @@
} }
.status-indicator.session-status.session-working, .status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting, .status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle { .status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@@ -194,6 +195,11 @@
--session-status-dot: var(--session-status-working-fg); --session-status-dot: var(--session-status-working-fg);
} }
.status-indicator.session-status.session-retrying {
color: var(--status-error);
--session-status-dot: var(--status-error);
}
.status-indicator.session-status.session-compacting { .status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg); color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg); --session-status-dot: var(--session-status-compacting-fg);
@@ -222,6 +228,10 @@
background-color: var(--session-status-working-bg); background-color: var(--session-status-working-bg);
} }
.status-indicator.session-status.session-retrying.session-status-list {
background-color: var(--status-error-bg);
}
.status-indicator.session-status.session-compacting.session-status-list { .status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg); background-color: var(--session-status-compacting-bg);
} }

View File

@@ -412,6 +412,19 @@
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
} }
.right-panel-accordion-header-row {
@apply flex items-center gap-2;
}
.right-panel-accordion-header-row .right-panel-accordion-trigger {
flex: 1 1 auto;
}
.right-panel-accordion-header-row .section-info-trigger {
flex: 0 0 auto;
margin-inline-end: 0.75rem;
}
.right-panel-accordion-trigger { .right-panel-accordion-trigger {
@apply w-full flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150; @apply w-full flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150;
color: var(--text-secondary); color: var(--text-secondary);
@@ -452,6 +465,8 @@
@apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150; @apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150;
color: var(--text-muted); color: var(--text-muted);
flex-shrink: 0; flex-shrink: 0;
border: none;
background-color: transparent;
} }
.section-info-trigger:hover { .section-info-trigger:hover {
@@ -459,6 +474,12 @@
background-color: var(--surface-hover); background-color: var(--surface-hover);
} }
.section-info-trigger:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.section-label { .section-label {
margin-inline-start: 2px; margin-inline-start: 2px;
} }

View File

@@ -107,6 +107,28 @@
@apply w-full; @apply w-full;
} }
.session-sidebar-toggle {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.65rem;
border: 1px solid var(--border-base);
border-radius: 0.75rem;
background: var(--surface-base);
min-height: 2rem;
width: fit-content;
max-width: 100%;
margin-left: auto;
}
.session-sidebar-toggle-title {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-primary);
line-height: 1.2;
}
.session-sidebar-controls .selector-trigger, .session-sidebar-controls .selector-trigger,
.session-sidebar-controls [data-model-selector-control], .session-sidebar-controls [data-model-selector-control],
.session-sidebar-controls .selector-trigger-label, .session-sidebar-controls .selector-trigger-label,
@@ -394,6 +416,7 @@ session-sidebar-controls .selector-trigger-primary {
} }
.status-indicator.session-status.session-working, .status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting, .status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle { .status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@@ -404,6 +427,11 @@ session-sidebar-controls .selector-trigger-primary {
--session-status-dot: var(--session-status-working-fg); --session-status-dot: var(--session-status-working-fg);
} }
.status-indicator.session-status.session-retrying {
color: var(--status-error);
--session-status-dot: var(--status-error);
}
.status-indicator.session-status.session-compacting { .status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg); color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg); --session-status-dot: var(--session-status-compacting-fg);
@@ -432,6 +460,10 @@ session-sidebar-controls .selector-trigger-primary {
background-color: var(--session-status-working-bg); background-color: var(--session-status-working-bg);
} }
.status-indicator.session-status.session-retrying.session-status-list {
background-color: var(--status-error-bg);
}
.status-indicator.session-status.session-compacting.session-status-list { .status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg); background-color: var(--session-status-compacting-bg);
} }
@@ -458,6 +490,16 @@ session-sidebar-controls .selector-trigger-primary {
border: 1px solid transparent; border: 1px solid transparent;
} }
.status-indicator.session-yolo-mode {
color: var(--accent-primary);
background-color: color-mix(in oklab, var(--accent-primary) 14%, transparent);
border-color: color-mix(in oklab, var(--accent-primary) 28%, transparent);
}
.status-indicator.session-yolo-mode .status-dot {
background-color: var(--accent-primary);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.session-list-container { .session-list-container {
min-width: 200px; min-width: 200px;

View File

@@ -17,6 +17,12 @@ export type {
export type SessionStatus = "idle" | "working" | "compacting" export type SessionStatus = "idle" | "working" | "compacting"
export interface SessionRetryState {
attempt: number
message: string
next: number
}
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus { export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
if (!status || status.type === "idle") { if (!status || status.type === "idle") {
return "idle" return "idle"
@@ -26,6 +32,18 @@ export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined)
return "working" return "working"
} }
export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): SessionRetryState | null {
if (!status || status.type !== "retry") {
return null
}
return {
attempt: typeof status.attempt === "number" ? status.attempt : 1,
message: typeof status.message === "string" ? status.message : "",
next: typeof status.next === "number" ? status.next : Date.now(),
}
}
// Our client-specific Session interface extending SDK Session // Our client-specific Session interface extending SDK Session
export interface Session export interface Session
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> { extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
@@ -40,6 +58,7 @@ export interface Session
pendingPermission?: boolean // Indicates if session is waiting on user permission pendingPermission?: boolean // Indicates if session is waiting on user permission
pendingQuestion?: boolean // Indicates if session is waiting on user input pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status status: SessionStatus // Single source of truth for session status
retry?: SessionRetryState | null // Retry metadata for transient backoff states
diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff) diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff)
} }