Compare commits
9 Commits
v0.13.1-de
...
v0.13.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d953dfe64 | ||
|
|
42589464e5 | ||
|
|
197dee2aea | ||
|
|
045d8da8b2 | ||
|
|
c9bd4b7395 | ||
|
|
41a5026331 | ||
|
|
d1a27ac31b | ||
|
|
37b3f85e61 | ||
|
|
55a6479c0e |
@@ -22,7 +22,7 @@
|
||||
"build:mac-x64": "npm run build:mac-x64 --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",
|
||||
"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": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import fs from "fs"
|
||||
import path, { join } from "path"
|
||||
import { execFileSync } from "child_process"
|
||||
import { spawnSync } from "child_process"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
@@ -11,7 +11,8 @@ const workspaceRoot = join(appDir, "..", "..")
|
||||
const serverRoot = join(appDir, "..", "server")
|
||||
const resourcesRoot = join(appDir, "electron", "resources")
|
||||
const serverDest = join(resourcesRoot, "server")
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||
@@ -34,27 +35,34 @@ function ensureServerDependencies() {
|
||||
}
|
||||
|
||||
log("installing production server dependencies")
|
||||
execFileSync(
|
||||
npmCmd,
|
||||
[
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--workspaces=false",
|
||||
"--package-lock=false",
|
||||
"--install-strategy=shallow",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
],
|
||||
{
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
const npmArgs = [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--workspaces=false",
|
||||
"--package-lock=false",
|
||||
"--install-strategy=shallow",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
]
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
npm_config_workspaces: "false",
|
||||
}
|
||||
|
||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||
const result = npmCli
|
||||
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
let voiceModeEnabled = false
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).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: {
|
||||
...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 }) {
|
||||
const opencodeEvent = input?.event
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -240,6 +240,10 @@ export interface SpeechSynthesisResponse {
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface VoiceModeStateResponse {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
|
||||
128
packages/server/src/clients/connection-manager.ts
Normal file
128
packages/server/src/clients/connection-manager.ts
Normal 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}`
|
||||
}
|
||||
@@ -81,6 +81,14 @@ export class FileSystemBrowser {
|
||||
return { path: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
writeFile(relativePath: string, contents: string): void {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("writeFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
fs.writeFileSync(resolved, contents, "utf-8")
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
|
||||
96
packages/server/src/plugins/voice-mode.ts
Normal file
96
packages/server/src/plugins/voice-mode.ts
Normal 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}`
|
||||
}
|
||||
@@ -29,6 +29,9 @@ import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
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 {
|
||||
bindHost: string
|
||||
@@ -173,6 +176,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
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 })
|
||||
|
||||
@@ -248,7 +258,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
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 })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
@@ -256,7 +271,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
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 })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
@@ -321,6 +342,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import type { ClientConnectionManager } from "../../clients/connection-manager"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
connectionManager: ClientConnectionManager
|
||||
}
|
||||
|
||||
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) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
const connection = ConnectionQuerySchema.parse(request.query ?? {})
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
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)
|
||||
|
||||
let closed = false
|
||||
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
const unregisterConnection = deps.connectionManager.register({
|
||||
...connection,
|
||||
close,
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
close()
|
||||
unregister()
|
||||
unregisterConnection()
|
||||
}
|
||||
|
||||
request.raw.on("close", 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { VoiceModeStateResponse } from "../../api-types"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { EventBus } from "../../events/bus"
|
||||
import type { Logger } from "../../logger"
|
||||
import { PluginChannelManager } from "../../plugins/channel"
|
||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||
import { VoiceModeManager } from "../../plugins/voice-mode"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
channel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
}
|
||||
|
||||
const PluginEventSchema = z.object({
|
||||
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const VoiceModeStateSchema = z.object({
|
||||
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) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
reply.raw.flushHeaders?.()
|
||||
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(() => {
|
||||
channel.send(request.params.id, buildPingEvent())
|
||||
deps.channel.send(request.params.id, buildPingEvent())
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
@@ -49,6 +58,22 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
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 workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
|
||||
@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentBodySchema = z.object({
|
||||
contents: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
@@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
priority: 2,
|
||||
priority: 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
priority: 1,
|
||||
priority: 2,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ export class WorkspaceManager {
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"dev:prep": "node ./scripts/dev-prep.js",
|
||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "tauri build"
|
||||
|
||||
@@ -56,11 +56,7 @@ async function ensureMonacoAssets() {
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] server build missing; running workspace build...")
|
||||
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
|
||||
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal 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)
|
||||
}
|
||||
@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
|
||||
open
|
||||
modal
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Only handle dismiss if dialog is dismissible (default: true)
|
||||
if (!open && payload.dismissible !== false) {
|
||||
dismiss(false, payload)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<Dialog.Overlay class="modal-overlay z-[60]" />
|
||||
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
@@ -140,10 +140,11 @@ const AlertDialog: Component = () => {
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-secondary">
|
||||
<label for="prompt-input" class="text-sm font-medium text-secondary">
|
||||
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="prompt-input"
|
||||
ref={(el) => {
|
||||
promptInputRef = el
|
||||
}}
|
||||
@@ -184,11 +185,10 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
content: string
|
||||
onSave?: (content: string) => void
|
||||
onContentChange?: (content: string) => void
|
||||
}
|
||||
|
||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = null
|
||||
}
|
||||
|
||||
const saveContent = () => {
|
||||
if (!editor || !props.onSave) return
|
||||
props.onSave(editor.getValue())
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = monaco.editor.create(host, {
|
||||
value: "",
|
||||
language: "plaintext",
|
||||
readOnly: true,
|
||||
readOnly: false,
|
||||
automaticLayout: true,
|
||||
lineNumbers: "on",
|
||||
minimap: { enabled: false },
|
||||
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
fontSize: 13,
|
||||
})
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
if (props.onContentChange) {
|
||||
props.onContentChange(editor.getValue())
|
||||
}
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
})()
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
@@ -420,6 +420,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onClose={closeLeftDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
@@ -530,6 +531,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onClose={closeRightDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
|
||||
@@ -24,6 +24,9 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f
|
||||
|
||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||
import { showToastNotification } from "../../../../lib/notifications"
|
||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import {
|
||||
@@ -102,6 +105,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
|
||||
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
|
||||
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
|
||||
|
||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||
@@ -539,6 +545,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
setBrowserSelectedOriginalContent(null)
|
||||
|
||||
// Phone: treat file selection as a commit action and close the overlay.
|
||||
if (props.isPhoneLayout()) {
|
||||
@@ -559,6 +567,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
@@ -566,6 +575,95 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const saveBrowserFile = async (content: string): Promise<boolean> => {
|
||||
const path = browserSelectedPath()
|
||||
if (!path) return false
|
||||
|
||||
// Check for conflict: agent edited file while user was editing
|
||||
const originalContent = browserSelectedOriginalContent()
|
||||
if (originalContent !== null) {
|
||||
try {
|
||||
const currentDiskContent = await requestData<FileContent>(
|
||||
browserClient().file.read({ path }),
|
||||
"file.read",
|
||||
)
|
||||
const diskContent = (currentDiskContent as any)?.content
|
||||
|
||||
// If disk content differs from what we originally loaded (agent edit)
|
||||
// AND differs from user's current edits, we have a conflict
|
||||
if (diskContent !== originalContent && diskContent !== content) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return false
|
||||
}
|
||||
// User chose to overwrite, proceed with save
|
||||
}
|
||||
} catch {
|
||||
// If we can't check for conflict, proceed with save
|
||||
}
|
||||
}
|
||||
|
||||
setBrowserSelectedSaving(true)
|
||||
try {
|
||||
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedOriginalContent(content) // Update original to match saved
|
||||
setBrowserSelectedDirty(false)
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
|
||||
variant: "success",
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.rightPanel.toast.saveError"),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setBrowserSelectedSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowserFileChange = (content: string) => {
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedDirty(true)
|
||||
}
|
||||
|
||||
const handleOpenBrowserFileRequest = async (path: string) => {
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (confirmed) {
|
||||
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
|
||||
if (!saveSuccess) {
|
||||
// Save failed - stay on current file, error toast already shown
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User chose not to save - clear dirty state and discard edits
|
||||
setBrowserSelectedDirty(false)
|
||||
}
|
||||
}
|
||||
await openBrowserFile(path)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "files") return
|
||||
if (browserLoading()) return
|
||||
@@ -578,6 +676,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -630,6 +729,22 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
}
|
||||
|
||||
const refreshFilesTab = async () => {
|
||||
// Prompt for confirmation if file has unsaved changes
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
void loadBrowserEntries(browserPath())
|
||||
const selected = browserSelectedPath()
|
||||
if (selected) {
|
||||
@@ -651,6 +766,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text) // Update original content after refresh
|
||||
setBrowserSelectedDirty(false) // Clear dirty after refresh
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
@@ -830,11 +947,15 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
browserSelectedDirty={browserSelectedDirty}
|
||||
browserSelectedSaving={browserSelectedSaving}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||
onOpenFile={(path: string) => void openBrowserFile(path)}
|
||||
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
onSave={(content: string) => void saveBrowserFile(content)}
|
||||
onContentChange={(content: string) => handleBrowserFileChange(content)}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
import { RefreshCw, Save } from "lucide-solid"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
@@ -21,13 +21,17 @@ interface FilesTabProps {
|
||||
browserSelectedContent: Accessor<string | null>
|
||||
browserSelectedLoading: Accessor<boolean>
|
||||
browserSelectedError: Accessor<string | null>
|
||||
browserSelectedDirty: Accessor<boolean>
|
||||
browserSelectedSaving: Accessor<boolean>
|
||||
|
||||
parentPath: Accessor<string | null>
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
onLoadEntries: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
onRequestOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
onSave: (content: string) => void
|
||||
onContentChange: (content: string) => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
@@ -38,6 +42,13 @@ interface FilesTabProps {
|
||||
}
|
||||
|
||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const handleSave = () => {
|
||||
const content = props.browserSelectedContent()
|
||||
if (content !== undefined && content !== null) {
|
||||
props.onSave(content)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
const entriesValue = props.browserEntries()
|
||||
const entries = entriesValue || []
|
||||
@@ -86,7 +97,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
<LazyMonacoFileViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={payload().path}
|
||||
content={payload().content}
|
||||
onSave={props.onSave}
|
||||
onContentChange={props.onContentChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
@@ -135,7 +152,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
props.onLoadEntries(item.path)
|
||||
return
|
||||
}
|
||||
props.onOpenFile(item.path)
|
||||
props.onRequestOpenFile(item.path)
|
||||
}}
|
||||
title={item.path}
|
||||
>
|
||||
@@ -168,14 +185,25 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
|
||||
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
|
||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
@@ -198,4 +226,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default FilesTab
|
||||
export default FilesTab
|
||||
@@ -83,6 +83,7 @@ interface MarkdownProps {
|
||||
isDark?: boolean
|
||||
size?: "base" | "sm" | "tight"
|
||||
disableHighlight?: boolean
|
||||
escapeRawHtml?: boolean
|
||||
onRendered?: () => void
|
||||
}
|
||||
|
||||
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
|
||||
const text = decodeHtmlEntitiesLocally(rawText)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const escapeRawHtml = Boolean(props.escapeRawHtml)
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||
const cacheId = resolvePartCacheId(part, text)
|
||||
const version = resolvePartVersion(part, text)
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
@@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
@@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
text: snapshot.text,
|
||||
html: renderedHtml,
|
||||
theme: snapshot.themeKey,
|
||||
mode: snapshot.version,
|
||||
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
||||
}
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
@@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||
suppressHighlight: !snapshot.highlightEnabled,
|
||||
escapeRawHtml: snapshot.escapeRawHtml,
|
||||
})
|
||||
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
@@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) {
|
||||
createEffect(() => {
|
||||
const snapshot = resolved()
|
||||
latestRequestKey = snapshot.requestKey
|
||||
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
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
|
||||
|
||||
@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
escapeRawHtml={props.messageType === "user"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -98,6 +98,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
variant: "warning",
|
||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ensureSessionParentExpanded,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
loadMessages,
|
||||
loading,
|
||||
renameSession,
|
||||
sessions as sessionStateSessions,
|
||||
@@ -53,6 +54,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
@@ -157,6 +159,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) return
|
||||
@@ -212,6 +215,32 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
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 = () => {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
@@ -285,6 +314,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -491,6 +521,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</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
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -86,6 +86,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SpeechSynthesisResponse,
|
||||
SpeechTranscriptionResponse,
|
||||
ServerMeta,
|
||||
VoiceModeStateResponse,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
WorktreeMap,
|
||||
WorktreeCreateRequest,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getClientIdentity } from "./client-identity"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||
@@ -234,6 +236,16 @@ export const serverApi = {
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ contents }),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||
@@ -338,6 +350,19 @@ export const serverApi = {
|
||||
{ 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(
|
||||
instanceId: string,
|
||||
processId: string,
|
||||
@@ -362,9 +387,15 @@ export const serverApi = {
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
|
||||
)
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
|
||||
connectEvents(
|
||||
onEvent: (event: WorkspaceEventPayload) => void,
|
||||
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) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
@@ -377,8 +408,26 @@ export const serverApi = {
|
||||
sseLogger.warn("EventSource error, closing stream")
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
58
packages/ui/src/lib/client-identity.ts
Normal file
58
packages/ui/src/lib/client-identity.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -95,6 +95,18 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.status": "Status",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
||||
"instanceShell.rightPanel.actions.save": "Save (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Do you want to save changes to \"{path}\" before switching?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Save",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Discard Changes",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "File was modified by the agent. Overwrite agent's changes?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Overwrite",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancel",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "File has unsaved changes. Refresh will discard your edits. Continue?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Refresh",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
|
||||
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
|
||||
"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.plan": "Plan",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "New session",
|
||||
"sessionList.actions.copyId.ariaLabel": "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.title": "Rename session",
|
||||
"sessionList.actions.delete.ariaLabel": "Delete session",
|
||||
"sessionList.actions.delete.title": "Delete session",
|
||||
"sessionList.copyId.success": "Session ID copied",
|
||||
"sessionList.copyId.error": "Unable to copy session ID",
|
||||
"sessionList.reload.error": "Unable to reload session",
|
||||
"sessionList.delete.error": "Unable to delete session",
|
||||
"sessionList.delete.title": "Delete session",
|
||||
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",
|
||||
|
||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||
"instanceShell.rightPanel.actions.refresh": "Actualizar",
|
||||
"instanceShell.rightPanel.actions.save": "Guardar (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "¿Deseas guardar los cambios en \"{path}\" antes de cambiar?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Guardar",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Descartar cambios",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "El archivo fue modificado por el agente. ¿Sobrescribir los cambios del agente?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Sobrescribir",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancelar",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "El archivo tiene cambios sin guardar. Actualizar discardará tus ediciones. ¿Continuar?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualizar",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
|
||||
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
|
||||
"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.plan": "Plan",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "Nueva sesión",
|
||||
"sessionList.actions.copyId.ariaLabel": "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.title": "Renombrar sesión",
|
||||
"sessionList.actions.delete.ariaLabel": "Eliminar sesión",
|
||||
"sessionList.actions.delete.title": "Eliminar sesión",
|
||||
"sessionList.copyId.success": "ID de sesión copiado",
|
||||
"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.title": "Eliminar sesión",
|
||||
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",
|
||||
|
||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||
"instanceShell.rightPanel.actions.refresh": "Actualiser",
|
||||
"instanceShell.rightPanel.actions.save": "Enregistrer (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Voulez-vous enregistrer les modifications de \"{path}\" avant de changer ?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Enregistrer",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Annuler les modifications",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "Le fichier a été modifié par l'agent. Écraser les modifications de l'agent ?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Écraser",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Annuler",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "Le fichier a des modifications non enregistrées. Actualiser supprimera vos modifications. Continuer ?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualiser",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
|
||||
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
|
||||
"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.plan": "Plan",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "Nouvelle session",
|
||||
"sessionList.actions.copyId.ariaLabel": "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.title": "Renommer la session",
|
||||
"sessionList.actions.delete.ariaLabel": "Supprimer la session",
|
||||
"sessionList.actions.delete.title": "Supprimer la session",
|
||||
"sessionList.copyId.success": "ID de session copié",
|
||||
"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.title": "Supprimer la session",
|
||||
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",
|
||||
|
||||
@@ -95,6 +95,18 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.status": "סטטוס",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
|
||||
"instanceShell.rightPanel.actions.refresh": "רענן",
|
||||
"instanceShell.rightPanel.actions.save": "שמור (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "האם ברצונך לשמור את השינויים לפני המעבר?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "שמור",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "בטל שינויים",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "הקובץ שונה על ידי הסוכן. לדרוס את שינויי הסוכן?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "דרוס",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "בטל",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "לקובץ יש שינויים שלא נשמרו. רענון יבטל את העריכות שלך. להמשיך?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "רענן",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
|
||||
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
||||
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "סשן חדש",
|
||||
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
|
||||
"sessionList.actions.copyId.title": "העתק מזהה סשן",
|
||||
"sessionList.actions.reload.ariaLabel": "טען מחדש סשן",
|
||||
"sessionList.actions.reload.title": "טען מחדש סשן",
|
||||
"sessionList.actions.rename.ariaLabel": "שנה שם סשן",
|
||||
"sessionList.actions.rename.title": "שנה שם סשן",
|
||||
"sessionList.actions.delete.ariaLabel": "מחק סשן",
|
||||
"sessionList.actions.delete.title": "מחק סשן",
|
||||
"sessionList.copyId.success": "מזהה סשן הועתק",
|
||||
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
|
||||
"sessionList.reload.error": "לא ניתן לטעון מחדש את הסשן",
|
||||
"sessionList.delete.error": "לא ניתן למחוק סשן",
|
||||
"sessionList.delete.title": "מחק סשן",
|
||||
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",
|
||||
|
||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||
"instanceShell.rightPanel.actions.refresh": "更新",
|
||||
"instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "「{path}」への変更を切り替え前に保存しますか?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "保存",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "変更を破棄",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "ファイルはエージェントによって変更されました。上書きしますか?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "上書き",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "キャンセル",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "ファイルには未保存の変更があります。更新すると編集が破棄されます。続行しますか?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "更新",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "キャンセル",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "ファイルを保存しました",
|
||||
"instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
|
||||
"instanceShell.rightPanel.sections.plan": "計画",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "新しいセッション",
|
||||
"sessionList.actions.copyId.ariaLabel": "セッション ID をコピー",
|
||||
"sessionList.actions.copyId.title": "セッション ID をコピー",
|
||||
"sessionList.actions.reload.ariaLabel": "セッションを再読み込み",
|
||||
"sessionList.actions.reload.title": "セッションを再読み込み",
|
||||
"sessionList.actions.rename.ariaLabel": "セッション名を変更",
|
||||
"sessionList.actions.rename.title": "セッション名を変更",
|
||||
"sessionList.actions.delete.ariaLabel": "セッションを削除",
|
||||
"sessionList.actions.delete.title": "セッションを削除",
|
||||
"sessionList.copyId.success": "セッション ID をコピーしました",
|
||||
"sessionList.copyId.error": "セッション ID をコピーできません",
|
||||
"sessionList.reload.error": "セッションを再読み込みできません",
|
||||
"sessionList.delete.error": "セッションを削除できません",
|
||||
"sessionList.delete.title": "セッションを削除",
|
||||
"sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。",
|
||||
|
||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||
"instanceShell.rightPanel.actions.refresh": "Обновить",
|
||||
"instanceShell.rightPanel.actions.save": "Сохранить (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Сохранить изменения в \"{path}\" перед переключением?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Сохранить",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Отменить изменения",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "Файл был изменён агентом. Перезаписать изменения агента?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Перезаписать",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Отмена",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "Файл имеет несохранённые изменения. Обновление отменит ваши правки. Продолжить?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Обновить",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Отмена",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Файл успешно сохранён",
|
||||
"instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
|
||||
"instanceShell.rightPanel.sections.plan": "План",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "Новая сессия",
|
||||
"sessionList.actions.copyId.ariaLabel": "Скопировать ID сессии",
|
||||
"sessionList.actions.copyId.title": "Скопировать ID сессии",
|
||||
"sessionList.actions.reload.ariaLabel": "Обновить сессию",
|
||||
"sessionList.actions.reload.title": "Обновить сессию",
|
||||
"sessionList.actions.rename.ariaLabel": "Переименовать сессию",
|
||||
"sessionList.actions.rename.title": "Переименовать сессию",
|
||||
"sessionList.actions.delete.ariaLabel": "Удалить сессию",
|
||||
"sessionList.actions.delete.title": "Удалить сессию",
|
||||
"sessionList.copyId.success": "ID сессии скопирован",
|
||||
"sessionList.copyId.error": "Не удалось скопировать ID сессии",
|
||||
"sessionList.reload.error": "Не удалось обновить сессию",
|
||||
"sessionList.delete.error": "Не удалось удалить сессию",
|
||||
"sessionList.delete.title": "Удалить сессию",
|
||||
"sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.",
|
||||
|
||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "文件",
|
||||
"instanceShell.rightPanel.tabs.status": "状态",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
||||
"instanceShell.rightPanel.actions.refresh": "刷新",
|
||||
"instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "切换前是否保存对 \"{path}\" 的更改?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "保存",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "放弃更改",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "文件已被代理修改。是否覆盖代理的更改?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "覆盖",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "取消",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "文件有未保存的更改。刷新将放弃您的编辑。继续?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "刷新",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "取消",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "文件保存成功",
|
||||
"instanceShell.rightPanel.toast.saveError": "保存文件失败",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "会话更改",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
|
||||
"instanceShell.rightPanel.sections.plan": "计划",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "新建会话",
|
||||
"sessionList.actions.copyId.ariaLabel": "复制会话 ID",
|
||||
"sessionList.actions.copyId.title": "复制会话 ID",
|
||||
"sessionList.actions.reload.ariaLabel": "重新加载会话",
|
||||
"sessionList.actions.reload.title": "重新加载会话",
|
||||
"sessionList.actions.rename.ariaLabel": "重命名会话",
|
||||
"sessionList.actions.rename.title": "重命名会话",
|
||||
"sessionList.actions.delete.ariaLabel": "删除会话",
|
||||
"sessionList.actions.delete.title": "删除会话",
|
||||
"sessionList.copyId.success": "已复制会话 ID",
|
||||
"sessionList.copyId.error": "无法复制会话 ID",
|
||||
"sessionList.reload.error": "无法重新加载会话",
|
||||
"sessionList.delete.error": "无法删除会话",
|
||||
"sessionList.delete.title": "删除会话",
|
||||
"sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。",
|
||||
|
||||
@@ -11,6 +11,7 @@ let highlighterPromise: Promise<Highlighter> | null = null
|
||||
let currentTheme: "light" | "dark" = "light"
|
||||
let isInitialized = false
|
||||
let highlightSuppressed = false
|
||||
let escapeRawHtmlEnabled = false
|
||||
let rendererSetup = false
|
||||
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
||||
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
||||
@@ -285,6 +286,14 @@ function setupRenderer(isDark: boolean) {
|
||||
return `<code class="inline-code">${escapeHtml(decoded)}</code>`
|
||||
}
|
||||
|
||||
renderer.html = (html: string) => {
|
||||
if (!escapeRawHtmlEnabled) {
|
||||
return html
|
||||
}
|
||||
|
||||
return escapeHtml(decodeHtmlEntities(html))
|
||||
}
|
||||
|
||||
marked.use({ renderer })
|
||||
rendererSetup = true
|
||||
}
|
||||
@@ -308,6 +317,7 @@ export async function renderMarkdown(
|
||||
content: string,
|
||||
options?: {
|
||||
suppressHighlight?: boolean
|
||||
escapeRawHtml?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!isInitialized) {
|
||||
@@ -316,6 +326,7 @@ export async function renderMarkdown(
|
||||
}
|
||||
|
||||
const suppressHighlight = options?.suppressHighlight ?? false
|
||||
const escapeRawHtml = options?.escapeRawHtml ?? false
|
||||
const decoded = decodeHtmlEntities(content)
|
||||
|
||||
if (!suppressHighlight) {
|
||||
@@ -324,13 +335,16 @@ export async function renderMarkdown(
|
||||
}
|
||||
|
||||
const previousSuppressed = highlightSuppressed
|
||||
const previousEscapeRawHtml = escapeRawHtmlEnabled
|
||||
highlightSuppressed = suppressHighlight
|
||||
escapeRawHtmlEnabled = escapeRawHtml
|
||||
|
||||
try {
|
||||
// Proceed to parse immediately - highlighting will be available on next render
|
||||
return marked.parse(decoded) as Promise<string>
|
||||
} finally {
|
||||
highlightSuppressed = previousSuppressed
|
||||
escapeRawHtmlEnabled = previousEscapeRawHtml
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import { getClientIdentity } from "./client-identity"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const RETRY_BASE_DELAY = 1000
|
||||
@@ -16,6 +17,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
|
||||
|
||||
class ServerEvents {
|
||||
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
|
||||
private openHandlers = new Set<() => void>()
|
||||
private source: EventSource | null = null
|
||||
private retryDelay = RETRY_BASE_DELAY
|
||||
|
||||
@@ -28,10 +30,24 @@ class ServerEvents {
|
||||
this.source.close()
|
||||
}
|
||||
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 = () => {
|
||||
logSse("Events stream connected")
|
||||
this.retryDelay = RETRY_BASE_DELAY
|
||||
this.openHandlers.forEach((handler) => handler())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +77,11 @@ class ServerEvents {
|
||||
bucket.add(handler)
|
||||
return () => bucket.delete(handler)
|
||||
}
|
||||
|
||||
onOpen(handler: () => void): () => void {
|
||||
this.openHandlers.add(handler)
|
||||
return () => this.openHandlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
export const serverEvents = new ServerEvents()
|
||||
|
||||
@@ -10,6 +10,8 @@ export type AlertDialogState = {
|
||||
variant?: AlertVariant
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/** When false, prevents dismissal via Escape key or backdrop click. Default: true */
|
||||
dismissible?: boolean
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { showToastNotification } from "../lib/notifications"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { loadSpeechCapabilities, speechCapabilities } from "./speech"
|
||||
import { getActiveSession, sessions } from "./session-state"
|
||||
@@ -30,6 +31,7 @@ interface PlaybackHandle {
|
||||
|
||||
const log = getLogger("actions")
|
||||
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 spokenKeysBySession = new Map<string, Set<string>>()
|
||||
@@ -43,6 +45,10 @@ let currentPlayback:
|
||||
let queueRunner: Promise<void> | null = null
|
||||
let playbackErrorShown = false
|
||||
|
||||
serverEvents.onOpen(() => {
|
||||
void syncConversationModesToServer()
|
||||
})
|
||||
|
||||
function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string {
|
||||
return `${instanceId}:${sessionId}:${messageId}:${partId}`
|
||||
}
|
||||
@@ -107,6 +113,9 @@ export function canUseConversationMode(): boolean {
|
||||
}
|
||||
|
||||
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
|
||||
const previous = isConversationModeEnabled(instanceId)
|
||||
if (previous === enabled) return
|
||||
|
||||
setConversationModeInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (enabled) {
|
||||
@@ -120,6 +129,23 @@ export function setConversationModeEnabled(instanceId: string, enabled: boolean)
|
||||
if (!enabled) {
|
||||
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 {
|
||||
@@ -188,7 +214,7 @@ export function handleConversationAssistantPartUpdated(instanceId: string, part:
|
||||
if (!isConversationModeEnabled(instanceId)) return
|
||||
if (!isSpeakableSession(instanceId, sessionId)) return
|
||||
|
||||
const text = resolveTextPartContent(part).trim()
|
||||
const text = extractLeadingSpokenBlock(resolveTextPartContent(part))
|
||||
if (!text) return
|
||||
|
||||
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" }))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -673,6 +673,7 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
|
||||
detail: tGlobal("sessionState.cleanup.deepConfirm.detail"),
|
||||
confirmLabel: tGlobal("sessionState.cleanup.deepConfirm.confirmLabel"),
|
||||
cancelLabel: tGlobal("sessionState.cleanup.deepConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
}
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
Reference in New Issue
Block a user