fix(desktop): 修复桌面端多个渲染和权限问题

1. 修复 Tauri CSP 导致 Emotion CSS-in-JS 样式失效的问题
   - Tauri 编译时自动给 style-src 添加 nonce,使 unsafe-inline 失效
   - 添加 dangerousDisableAssetCspModification 阻止 Tauri 修改 style-src
   - 解决 DiffViewer 背景色/缩进等样式丢失

2. 修复 CodeViewer 代码块内容不显示的问题
   - react-shiki 异步加载 WASM 期间返回 null,代码区域空白
   - 添加 CodeArea 组件,纯文本 fallback + MutationObserver 检测加载完成

3. 修复 Bypass 权限模式切换不生效的问题
   - CLI 的 bypassPermissions 需要启动时带 --dangerously-skip-permissions
   - 运行中切换被 CLI 静默拒绝,前端不知情
   - 改为重启 CLI 子进程以正确的参数启动

4. 修复 stream_event tool input JSON 解析失败时 DiffViewer 不渲染
   - content_block_stop 的 JSON parse 失败时暂存到 pendingToolBlocks
   - 由后续 assistant 消息补发完整的 tool input
This commit is contained in:
程序员阿江(Relakkes) 2026-04-16 10:50:02 +08:00
parent 870e03d88b
commit 0b4c742d55
4 changed files with 181 additions and 29 deletions

View File

@ -25,6 +25,7 @@
}
],
"security": {
"dangerousDisableAssetCspModification": ["style-src"],
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
}
},

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { ShikiHighlighter } from 'react-shiki'
import 'react-shiki/css'
import { CopyButton } from '../shared/CopyButton'
@ -45,6 +45,75 @@ const warmCodeTheme = {
],
}
/**
* Wraps ShikiHighlighter with a plain-text fallback so the code area
* is never empty while the async WASM / language-grammar load is in-flight,
* or if highlighting fails entirely.
*/
function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
const containerRef = useRef<HTMLDivElement>(null)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// ShikiHighlighter renders `null` until the async highlight completes.
// Watch for real content appearing via MutationObserver so we can hide
// the plain-text fallback as soon as highlighted output is in the DOM.
const el = containerRef.current
if (!el) return
const check = () => {
const shikiContainer = el.querySelector('[data-testid="shiki-container"]')
// shiki renders a <code> element inside its container once highlighting is done
if (shikiContainer?.querySelector('code')) {
setLoaded(true)
}
}
check()
const observer = new MutationObserver(check)
observer.observe(el, { childList: true, subtree: true })
return () => observer.disconnect()
}, [code, language])
return (
<div ref={containerRef} className="code-viewer-area max-h-[420px] overflow-auto bg-[#FDFCF9]">
{/* Plain-text fallback shown until Shiki finishes highlighting */}
{!loaded && (
<pre
style={{
margin: 0,
padding: '0.5rem 12px',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: '1.45',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: '#24201E',
}}
>
{code}
</pre>
)}
<div style={loaded ? undefined : { position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
<ShikiHighlighter
language={language || 'text'}
theme={warmCodeTheme}
showLineNumbers={showLineNumbers}
showLanguage={false}
addDefaultStyles={false}
style={{
margin: 0,
padding: '0.5rem 0',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: '1.45',
}}
>
{code}
</ShikiHighlighter>
</div>
</div>
)
}
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: Props) {
const [expanded, setExpanded] = useState(false)
@ -72,24 +141,11 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = tr
</div>
{/* Code area */}
<div className="code-viewer-area max-h-[420px] overflow-auto bg-[#FDFCF9]">
<ShikiHighlighter
language={language || 'text'}
theme={warmCodeTheme}
showLineNumbers={effectiveShowLineNumbers}
showLanguage={false}
addDefaultStyles={false}
style={{
margin: 0,
padding: '0.5rem 0',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: '1.45',
}}
>
{visibleCode}
</ShikiHighlighter>
</div>
<CodeArea
code={visibleCode}
language={language}
showLineNumbers={effectiveShowLineNumbers}
/>
{/* Expand/collapse toggle */}
{showExpandToggle && (

View File

@ -23,6 +23,7 @@ type SessionProcess = {
proc: ReturnType<typeof Bun.spawn>
outputCallbacks: Array<(msg: any) => void>
workDir: string
permissionMode: string
sdkToken: string
sdkSocket: { send(data: string): void } | null
pendingOutbound: string[]
@ -142,6 +143,7 @@ export class ConversationService {
proc,
outputCallbacks: [],
workDir,
permissionMode: options?.permissionMode || 'default',
sdkToken: this.getSdkTokenFromUrl(sdkUrl),
sdkSocket: null,
pendingOutbound: [],
@ -285,6 +287,16 @@ export class ConversationService {
return this.sessions.has(sessionId)
}
getSessionWorkDir(sessionId: string): string {
const session = this.sessions.get(sessionId)
return session?.workDir || ''
}
getSessionPermissionMode(sessionId: string): string {
const session = this.sessions.get(sessionId)
return session?.permissionMode || 'default'
}
authorizeSdkConnection(
sessionId: string,
token: string | null | undefined,

View File

@ -305,12 +305,62 @@ function handleSetPermissionMode(
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
) {
const { sessionId } = ws.data
// Switching to/from bypassPermissions requires the CLI to be (re)started with
// --dangerously-skip-permissions. The CLI rejects a runtime set_permission_mode
// to bypassPermissions if it wasn't launched with that flag. Rather than just
// sending the SDK message (which would silently fail), restart the CLI subprocess
// with the correct arguments so the new permission mode takes effect.
const needsRestart =
conversationService.hasSession(sessionId) &&
(message.mode === 'bypassPermissions' || conversationService.getSessionPermissionMode(sessionId) === 'bypassPermissions')
if (needsRestart) {
void restartSessionWithPermissionMode(ws, sessionId, message.mode)
return
}
const ok = conversationService.setPermissionMode(sessionId, message.mode)
if (!ok) {
console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`)
}
}
async function restartSessionWithPermissionMode(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
mode: string,
): Promise<void> {
try {
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Restarting session with new permissions...' })
// Persist the new mode first so it's read on restart
await settingsService.setPermissionMode(mode)
const workDir = conversationService.getSessionWorkDir(sessionId)
conversationService.stopSession(sessionId)
// Rebuild runtime settings (will pick up the persisted mode)
const runtimeSettings = await getRuntimeSettings()
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
`?token=${encodeURIComponent(crypto.randomUUID())}`
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
sendMessage(ws, { type: 'status', state: 'idle' })
console.log(`[WS] Restarted CLI for ${sessionId} with permission mode: ${mode}`)
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: `Failed to restart session with new permission mode: ${errMsg}`,
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
}
}
function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
const { sessionId } = ws.data
console.log(`[WS] Stop generation requested for session: ${sessionId}`)
@ -386,6 +436,9 @@ type SessionStreamState = {
hasReceivedStreamEvents: boolean
activeBlockTypes: Map<number, 'text' | 'tool_use'>
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string }>
/** Tool blocks whose input JSON failed to parse in content_block_stop.
* The assistant message carries the complete input defer to that. */
pendingToolBlocks: Map<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
}
const sessionStreamStates = new Map<string, SessionStreamState>()
@ -397,6 +450,7 @@ function getStreamState(sessionId: string): SessionStreamState {
hasReceivedStreamEvents: false,
activeBlockTypes: new Map(),
activeToolBlocks: new Map(),
pendingToolBlocks: new Map(),
}
sessionStreamStates.set(sessionId, state)
}
@ -427,7 +481,20 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
for (const block of cliMsg.message.content) {
if (streamState.hasReceivedStreamEvents) {
// Everything was already sent via stream_event — skip all blocks
// Stream events handled most blocks — but any tool_use whose
// input JSON failed to parse in content_block_stop was deferred.
// Emit those now with the complete input from the assistant message.
if (block.type === 'tool_use' && streamState.pendingToolBlocks.has(block.id)) {
const pending = streamState.pendingToolBlocks.get(block.id)!
streamState.pendingToolBlocks.delete(block.id)
messages.push({
type: 'tool_use_complete',
toolName: pending.toolName || block.name,
toolUseId: block.id,
input: block.input,
parentToolUseId: pending.parentToolUseId,
})
}
} else {
// No stream events received — this is the only source, process everything
if (block.type === 'thinking' && block.thinking) {
@ -450,8 +517,9 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
}
}
// Reset flag for next turn
// Reset flags for next turn
streamState.hasReceivedStreamEvents = false
streamState.pendingToolBlocks.clear()
return messages
}
return []
@ -549,18 +617,33 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
const toolBlock = streamState.activeToolBlocks.get(index)
streamState.activeToolBlocks.delete(index)
if (toolBlock) {
const parentToolUseId =
typeof cliMsg.parent_tool_use_id === 'string'
? cliMsg.parent_tool_use_id
: undefined
let parsedInput = null
try { parsedInput = JSON.parse(toolBlock.inputJson) } catch {}
return [{
type: 'tool_use_complete',
if (parsedInput !== null) {
return [{
type: 'tool_use_complete',
toolName: toolBlock.toolName,
toolUseId: toolBlock.toolUseId,
input: parsedInput,
parentToolUseId,
}]
}
// JSON parse failed — defer to the assistant message which
// carries the complete, already-parsed tool input.
console.warn(
`[WS] Tool input JSON parse failed for ${toolBlock.toolName} (${toolBlock.toolUseId}), deferring to assistant message`,
)
streamState.pendingToolBlocks.set(toolBlock.toolUseId, {
toolName: toolBlock.toolName,
toolUseId: toolBlock.toolUseId,
input: parsedInput,
parentToolUseId:
typeof cliMsg.parent_tool_use_id === 'string'
? cliMsg.parent_tool_use_id
: undefined,
}]
parentToolUseId,
})
}
}
return []