Compare commits
14 Commits
v0.13.1-de
...
v0.13.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
995fb3b6a3 | ||
|
|
aeb0ff11b3 | ||
|
|
b61cfbd9f9 | ||
|
|
481dd1a88a | ||
|
|
3f6cdd36f3 | ||
|
|
fe932c8307 | ||
|
|
64ac885157 | ||
|
|
1d953dfe64 | ||
|
|
42589464e5 | ||
|
|
197dee2aea | ||
|
|
045d8da8b2 | ||
|
|
c9bd4b7395 | ||
|
|
41a5026331 | ||
|
|
d1a27ac31b |
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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}`
|
||||||
|
}
|
||||||
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 { 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()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}\"? לא ניתן לבטל פעולה זו.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}\" を削除しますか?この操作は元に戻せません。",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}\"? Это действие нельзя отменить.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}”?此操作无法撤销。",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
81
packages/ui/src/stores/permission-auto-accept.ts
Normal file
81
packages/ui/src/stores/permission-auto-accept.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user