feat: align conversation state, composer and scoped approvals

This commit is contained in:
dot committed 2026-10-21 03:46:53 +09:00
1 parent 771ca540f7
commit 308b98df23
61 files changed
+1696 -287

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
"author": "YCode", "author": "YCode",
"main": "./out/main/index.mjs", "main": "./out/main/index.mjs",
"scripts": { "scripts": {
"test:alignment": "node scripts/run-alignment-regression.mjs",
"test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs", "test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs",
"test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs", "test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs",
"test:recommended-skills:ui": "node scripts/test-recommended-skills-ui.mjs", "test:recommended-skills:ui": "node scripts/test-recommended-skills-ui.mjs",
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import type { ChatMessage } from '../src/shared/types'
import { projectQuestionAnswers } from '../src/renderer/src/workbuddy/conversation/question-projection'
import { buildCompletedFoldModel } from '../src/renderer/src/workbuddy/conversation/completed-fold'
import { CompletedFold } from '../src/renderer/src/workbuddy/conversation/CompletedFold'
import { StreamingFooter, resolveRuntimeStatusText, isLoadingTipPaused } from '../src/renderer/src/workbuddy/conversation/StreamingFooter'
import MessageBubble from '../src/renderer/src/components/MessageBubble'
const message: ChatMessage = { id:'trace', role:'assistant', content:'A B', createdAt:1, status:'streaming', parts:[
{type:'text',text:'A'}, {type:'reasoning',id:'r1',text:'thought one'}, {type:'tool',toolId:'t1'},
{type:'text',text:'B'}, {type:'reasoning',id:'r2',text:'thought two'}, {type:'tool',toolId:'t2'}
], tools: ['t1','t2'].map(id=>({id,name:'Read',args:{file_path:id},argsRaw:'{}',status:'ok'})) }
const model=buildCompletedFoldModel(message.parts!,{turnFinished:false})!
assert.deepEqual(model.summaryGroups.map(group=>[group.startIndex,group.shouldFold]),[[1,true],[4,false]])
const live=renderToStaticMarkup(<MessageBubble message={message}/> )
assert.equal((live.match(/cr-live-event-feed/g)||[]).length,1)
assert.match(live,/data-activity-tool-id="t1"/)
assert.match(live.replace(/<[^>]*>/g,''),/thought two/)
assert.equal((live.match(/data-tool-name="Read"/g)||[]).length,2)
const expanded=renderToStaticMarkup(<CompletedFold parts={message.parts!} turnFinished turnDurationMs={1000} expandFinishedEvents renderUnit={(p,i)=><i data-index={i}>{p.type}</i>}/> )
assert.deepEqual([...expanded.matchAll(/data-index="(\d+)"/g)].map(m=>Number(m[1])),[0,1,2,3,4,5])
const question: ChatMessage={...message,parts:[{type:'tool',toolId:'q'},{type:'text',text:'after'}],tools:[{id:'q',name:'AskUserQuestion',args:{},argsRaw:'{}',status:'ok'}],interactions:[{kind:'question',id:'i',runId:'r',toolCallId:'q',title:'Q',status:'answered',questions:[],answer:{questions:[{id:'q1',question:'Choose',answers:['yes']}]}}]}
const projection=projectQuestionAnswers(question)
assert.equal(projection.unpositioned.length,0)
assert.deepEqual(projection.tools[0].metadata?.questionAnswer,question.interactions![0].kind==='question'&&question.interactions![0].answer)
assert.equal(question.tools![0].metadata,undefined,'projection must not mutate persisted source')
assert.equal(projectQuestionAnswers({...question,tools:[],parts:[{type:'text',text:'old'}]}).unpositioned.length,1)
assert.equal(projectQuestionAnswers({...question,interactions:[...question.interactions!,...question.interactions!]}).tools.length,1)
assert.equal(resolveRuntimeStatusText('准备项目',{source:'agent',phase:'tool',toolName:'Read'}),'准备项目')
assert.equal(resolveRuntimeStatusText(null,{source:'interaction',phase:'waiting-answer'}),'等待回答')
assert.equal(resolveRuntimeStatusText(null,{source:'agent',phase:'finishing'}),'正在收尾')
assert.equal(renderToStaticMarkup(<StreamingFooter locale="zh" reasoningOnly/>),'')
assert.match(renderToStaticMarkup(<StreamingFooter locale="zh" reasoningOnly progress={{source:'interaction',phase:'waiting-approval'}}/>),/等待授权/)
console.log('alignment-conversation: local groups, original expanded order, question projection and truthful status passed')
assert.equal(isLoadingTipPaused({hovered:false,focused:true}),true)
assert.equal(isLoadingTipPaused({hovered:true,focused:false}),true)
assert.equal(isLoadingTipPaused({hovered:false,focused:false}),false)
@@ -15,7 +15,7 @@ profile.name = 'Renamed Model'
assert.deepEqual(metadata, { modelId: 'profile-1', modelName: 'Frozen Model' }) assert.deepEqual(metadata, { modelId: 'profile-1', modelName: 'Frozen Model' })
const newAssistantMessage: ChatMessage = { const newAssistantMessage: ChatMessage = {
id: 'assistant-1', role: 'assistant', content: 'answer', createdAt: renderTime.getTime(), status: 'done', ...metadata id: 'assistant-1', role: 'assistant', content: 'answer', createdAt: 1, turnFinishedAt: renderTime.getTime(), status: 'done', ...metadata
} }
const newFooter = renderToStaticMarkup(<AssistantMessageDetails message={newAssistantMessage} />) const newFooter = renderToStaticMarkup(<AssistantMessageDetails message={newAssistantMessage} />)
assert.match(newFooter, /cb-credit-usage-text cb-credit-usage-text--feedback/) assert.match(newFooter, /cb-credit-usage-text cb-credit-usage-text--feedback/)
@@ -30,7 +30,7 @@ const historicalMessage: ChatMessage = {
id: 'assistant-old', role: 'assistant', content: 'legacy answer', createdAt: renderTime.getTime(), status: 'done' id: 'assistant-old', role: 'assistant', content: 'legacy answer', createdAt: renderTime.getTime(), status: 'done'
} }
const historicalFooter = renderToStaticMarkup(<AssistantMessageDetails message={historicalMessage} />) const historicalFooter = renderToStaticMarkup(<AssistantMessageDetails message={historicalMessage} />)
assert.doesNotMatch(historicalFooter, /cb-credit-usage-text/) assert.doesNotMatch(historicalFooter, /cb-credit-usage-text|cb-message-time-tip/, 'no fabricated creation-time completion timestamp')
assert.doesNotMatch(historicalFooter, /Frozen Model|Renamed Model/) assert.doesNotMatch(historicalFooter, /Frozen Model|Renamed Model/)
const footerCss = readFileSync(resolve('src/renderer/src/components/assistant-message-details.css'), 'utf8') const footerCss = readFileSync(resolve('src/renderer/src/components/assistant-message-details.css'), 'utf8')
+6
View File
@@ -0,0 +1,6 @@
import { build } from 'esbuild'
await build({entryPoints:['scripts/claude-approval-policy-regression.ts'],outfile:'.tmp/claude-approval-policy-regression.mjs',bundle:true,platform:'node',format:'esm',external:['@anthropic-ai/claude-agent-sdk'],logLevel:'warning',banner:{js:`import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`},plugins:[{name:'electron-unit-stub',setup(plugin){plugin.onResolve({filter:/^electron$/},()=>({path:'electron',namespace:'unit'}));plugin.onLoad({filter:/.*/,namespace:'unit'},()=>({loader:'js',contents:`export const app={getPath:()=>process.env.CLAUDE_TEST_USER_DATA,getAppPath:()=>process.cwd()};export const safeStorage={isEncryptionAvailable:()=>false};`}))}}]})
await build({entryPoints:['scripts/claude-grant-replay-regression.ts'],outfile:'.tmp/claude-grant-replay-regression.mjs',bundle:true,platform:'node',format:'esm',logLevel:'warning',banner:{js:`import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`},plugins:[{name:'isolated-sdk-unit-stubs',setup(plugin){
plugin.onResolve({filter:/^(electron|@anthropic-ai\/claude-agent-sdk)$/},args=>({path:args.path,namespace:'unit'}))
plugin.onLoad({filter:/.*/,namespace:'unit'},args=>({loader:'js',contents:args.path==='electron'?`export const app={getPath:()=>process.env.CLAUDE_TEST_USER_DATA,getAppPath:()=>process.cwd()};export const safeStorage={isEncryptionAvailable:()=>false};`:`export const query=(input)=>globalThis.__SDK_QUERY_FIXTURE(input);export const createSdkMcpServer=x=>x;export const tool=(...x)=>x;`}))
}}]})
+9 -5
View File
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { useChatStore } from '../src/renderer/src/stores/chat' import { useChatStore } from '../src/renderer/src/stores/chat'
import { useSettingsStore } from '../src/renderer/src/stores/settings'
import type { ActiveChatRun, SendResult } from '../src/shared/ipc' import type { ActiveChatRun, SendResult } from '../src/shared/ipc'
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta, ToolCallRecord } from '../src/shared/types' import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta, ToolCallRecord } from '../src/shared/types'
const session: SessionMeta = { id: 'session-1', title: 'Regression', createdAt: 1, updatedAt: 1 } const session: SessionMeta = { id: 'session-1', title: 'Regression', modelId: 'model-profile-1', createdAt: 1, updatedAt: 1 }
function tool(overrides: Partial<ToolCallRecord> = {}): ToolCallRecord { function tool(overrides: Partial<ToolCallRecord> = {}): ToolCallRecord {
return { return {
@@ -73,6 +74,7 @@ function activeRunSnapshot(
} }
function resetStore(): void { function resetStore(): void {
useSettingsStore.setState({ models: [{ id: 'model-profile-1', name: 'Fixture', providerId: 'fixture', model: 'fixture', baseUrl: 'http://localhost', hasApiKey: false, keyStorage: 'none' }], selectedModelId: 'model-profile-1' })
useChatStore.setState({ useChatStore.setState({
sessions: [session], sessions: [session],
currentSessionId: session.id, currentSessionId: session.id,
@@ -85,7 +87,7 @@ function resetStore(): void {
sendingSessions: [], sendingSessions: [],
running: null, running: null,
sending: false, sending: false,
loading: false, loading: false, loadError: null,
error: null, error: null,
draftContext: {}, draftContext: {},
draftId: 0 draftId: 0
@@ -188,7 +190,7 @@ async function testFailedCreatedSessionIsRemoved(): Promise<void> {
assert.equal(created.id in state.sessionContextWindow, false) assert.equal(created.id in state.sessionContextWindow, false)
assert.equal(state.sessions.some((item) => item.id === created.id), false) assert.equal(state.sessions.some((item) => item.id === created.id), false)
assert.equal(state.draftId, 7, 'failure recovery must preserve the draft owner id') assert.equal(state.draftId, 7, 'failure recovery must preserve the draft owner id')
assert.deepEqual(state.draftContext, { workspaceId: 'space-draft' }, 'failure recovery must preserve draft context') assert.deepEqual(state.draftContext, { workspaceId: 'space-draft', modelId: 'model-profile-1' }, 'failure recovery must preserve draft context')
assert.equal(state.sending, false) assert.equal(state.sending, false)
assert.match(state.error ?? '', /send rejected after create/) assert.match(state.error ?? '', /send rejected after create/)
} }
@@ -326,6 +328,8 @@ async function testModelChangePreservesFrozenUsage(): Promise<void> {
assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used, assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used,
'a rejected model change must leave the existing usage sample intact') 'a rejected model change must leave the existing usage sample intact')
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000) assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000)
window.ycode.session.updateContext = async (_id, patch) => ({ ...session, ...patch })
await useChatStore.getState().selectModel('model-profile-1') // Explicit repair clears the failed request-settings barrier.
} }
async function testRevertRestoresBranchUsage(): Promise<void> { async function testRevertRestoresBranchUsage(): Promise<void> {
@@ -580,7 +584,7 @@ async function testHydrationBuffersKnownRunAndFiltersByWatermark(): Promise<void
installFakeWindow(async () => sendResult('unused')) installFakeWindow(async () => sendResult('unused'))
window.ycode.chat.activeRun = async () => active.promise window.ycode.chat.activeRun = async () => active.promise
const selection = useChatStore.getState().selectSession(session.id) const selection = useChatStore.getState().selectSession(session.id, { reload: true })
useChatStore.getState().applyEvent({ useChatStore.getState().applyEvent({
sessionId: session.id, sessionId: session.id,
runId: 'run-watermark', runId: 'run-watermark',
@@ -648,7 +652,7 @@ async function testTerminalActiveSnapshotDoesNotCreateRunning(): Promise<void> {
if (status === 'error') assistant.error = 'snapshot failed' if (status === 'error') assistant.error = 'snapshot failed'
window.ycode.chat.activeRun = async () => snapshot window.ycode.chat.activeRun = async () => snapshot
await useChatStore.getState().selectSession(session.id) await useChatStore.getState().selectSession(session.id, { reload: true })
const state = useChatStore.getState() const state = useChatStore.getState()
assert.equal(state.messages.find((message) => message.id === snapshot.assistantMessageId)?.status, status) assert.equal(state.messages.find((message) => message.id === snapshot.assistantMessageId)?.status, status)
@@ -0,0 +1,35 @@
/** Host policy unit tests only: no query(), CLI process or model request. */
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel'
import { clearSessionGrants, sessionGrantRules } from '../src/main/agent/claude/session-grants'
import type { ClaudeKernelOptions } from '../src/main/agent/claude/claude-kernel'
const temp = await mkdtemp(join(tmpdir(),'approval-unit-'))
process.env.CLAUDE_TEST_USER_DATA=temp
const create = (id:string, mode:'craft'|'ask'|'plan'='craft', permissionMode:'default-sandbox'|'full-access'='default-sandbox') => new ClaudeKernel({sessionId:id,workspaceRoot:temp,model:{} as ClaudeKernelOptions['model'],permissionMode,agentMode:mode,onSessionId:()=>{}})
const suggestions=[{type:'addRules',behavior:'allow',destination:'projectSettings',rules:[{toolName:'Bash',ruleContent:'echo safe:*'},{toolName:'Read',ruleContent:'/tmp/owned/**'}]}]
let calls=0
const setup = (kernel:ClaudeKernel, choose='allow-always') => {
const controller = new AbortController()
const raw=kernel as any
raw.instance={applyFlagSettings:async()=>{},close:()=>{}}
raw.active={settled:false,request:{runId:'r',signal:controller.signal,emit:()=>{},interactions:{approval:async (...args:any[])=>{calls++;assert.equal(args[5].toolName,'Bash');const decision={kind:'approval',optionId:choose};await args[6]?.(decision);return decision}}}}
return { raw, controller, invoke:(extra:object={})=>raw.canUseTool('Bash',{command:'echo safe'}, {signal:controller.signal,toolUseID:'t',suggestions,...extra}) }
}
try {
const a=setup(create('a')); const result=await a.invoke()
assert.equal(result.behavior,'allow'); assert.equal(result.updatedPermissions.length,1)
assert.equal(result.updatedPermissions[0].rules.length,2); assert.equal(result.updatedPermissions[0].destination,'session')
assert.equal(sessionGrantRules('a',temp).length,2);assert.equal(sessionGrantRules('b',temp).length,0)
const once=setup(create('once'),'allow-once'); assert.equal((await once.invoke()).updatedPermissions,undefined);assert.equal(sessionGrantRules('once',temp).length,0)
const denied=setup(create('denied'),'reject-once');assert.equal((await denied.invoke()).behavior,'deny')
for(const mode of ['ask','plan'] as const) {const old=calls;assert.equal((await setup(create(mode,mode,'full-access')).invoke()).behavior,'deny');assert.equal(calls,old)}
const full=setup(create('full','craft','full-access'));const old=calls;assert.equal((await full.invoke()).behavior,'allow');assert.equal(calls,old)
await full.invoke({matchedAskRule:{source:'projectSettings',toolName:'Bash',ruleContent:'echo safe:*'}});assert.equal(calls,old+1)
const failed=setup(create('failed'));failed.raw.instance.applyFlagSettings=async()=>{throw new Error('apply failed')};assert.equal((await failed.invoke()).behavior,'deny');assert.equal(sessionGrantRules('failed',temp).length,0)
const stale=setup(create('stale'));stale.raw.active.request.interactions.approval=async()=>{stale.controller.abort();return {kind:'approval',optionId:'allow-always'}}
assert.equal((await stale.invoke()).behavior,'deny');assert.equal(sessionGrantRules('stale',temp).length,0)
console.log('Claude host approval policy unit checks passed (no SDK process/application/matching verification)')
} finally {clearSessionGrants();await rm(temp,{recursive:true,force:true})}
+50
View File
@@ -0,0 +1,50 @@
/** Mock SDK transport: tests host replay lifecycle, NOT SDK permission matching. */
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel'
import { rememberSessionGrant, sessionGrantProposal, clearSessionGrants } from '../src/main/agent/claude/session-grants'
const temp=await mkdtemp(join(tmpdir(),'grant-replay-unit-'));process.env.CLAUDE_TEST_USER_DATA=temp
const instances:any[]=[]
let fail=true
;(globalThis as any).__SDK_QUERY_FIXTURE=(input:any)=>{
let end!:()=>void;const done=new Promise<void>(resolve=>{end=resolve})
const instance={input,closed:false,settings:[] as any[],async *[Symbol.asyncIterator](){await done},close(){this.closed=true;end()},async applyFlagSettings(value:any){this.settings.push(value);if(fail)throw new Error('fixture replay failure')},async setPermissionMode(){},async getContextUsage(){return {categories:[]}}}
instances.push(instance);return instance
}
const model={profile:{id:'fixture',model:'fixture',authMode:'api-key'},config:{model:'fixture',baseUrl:'http://127.0.0.1:1',apiKey:'not-used',authMode:'api-key'}}
const kernel=new ClaudeKernel({sessionId:'a',workspaceRoot:temp,model:model as any,permissionMode:'default-sandbox',agentMode:'craft',onSessionId:()=>{}})
try{
const grant=sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'session',rules:[{toolName:'Bash',ruleContent:'echo safe:*'}]}])!
rememberSessionGrant('a',grant,temp)
await assert.rejects(kernel.ensureStarted(),/恢复本会话授权失败/)
assert.equal(instances.length,1);assert.equal(instances[0].closed,true)
assert.equal((kernel as any).instance,null);assert.equal((kernel as any).input,null)
// Complete retired iterator cleanup before attempting a second kernel start.
await (kernel as any).draining
await assert.rejects(kernel.ensureStarted(),/恢复本会话授权失败/)
assert.equal(instances.length,2,'failure never silently reuses poisoned query')
await (kernel as any).draining
fail=false;await kernel.ensureStarted()
assert.equal(instances.length,3)
assert.equal((kernel as any).draining,null,'retired consumer draining lock cleared')
assert.deepEqual(await kernel.getContextSummary(),{categories:[]},'context queries recover after failed replay')
const raw=kernel as any
raw.active={settled:false,appends:new Map(),groupIds:['original'],request:{signal:new AbortController().signal,onSegmentStart:async()=>{},onSegmentComplete:async()=>{}}}
const append=kernel.appendInput({userMessageId:'append',prompt:'fixture'})
assert.equal(raw.active.appends.size,1,'append accepted after retry, no stale draining lock')
raw.active.appends.get('append').resolve();await append
raw.active=null
assert.deepEqual(instances[2].settings,[{permissions:{allow:['Bash(echo safe:*)']}}])
const hook=instances[2].input.options.hooks.PreToolUse[0].hooks[0]
;(kernel as any).active={request:{signal:new AbortController().signal}}
await kernel.setPolicy('full-access','plan')
const denied=await hook({hook_event_name:'PreToolUse',tool_name:'Bash',tool_input:{command:'echo safe'}})
assert.equal(denied.hookSpecificOutput.permissionDecision,'deny','plan denies even matching replay grant')
;(kernel as any).active=null
const other=new ClaudeKernel({sessionId:'other',workspaceRoot:temp,model:model as any,permissionMode:'default-sandbox',agentMode:'craft',onSessionId:()=>{}})
await other.ensureStarted();assert.equal(instances.at(-1).settings.length,0,'other session receives no grants')
await other.close()
console.log('Mock SDK replay lifecycle passed: fail closed, retry, session isolation, independent mode guard')
}finally{(kernel as any).active=null;await kernel.close();clearSessionGrants();await rm(temp,{recursive:true,force:true})}
+10 -30
View File
@@ -95,10 +95,10 @@ const activePrefixSummary = buildCompletedFoldModel([
], { turnFinished: false }) ], { turnFinished: false })
assert.ok(activePrefixSummary) assert.ok(activePrefixSummary)
assert.deepEqual(activePrefixSummary.summaryGroups.map(group => [group.startIndex, group.endIndex, group.shouldFold]), [ assert.deepEqual(activePrefixSummary.summaryGroups.map(group => [group.startIndex, group.endIndex, group.shouldFold]), [
[0, 1, true], [3, 4, true] [0, 1, true], [3, 4, false]
]) ])
assert.equal(activePrefixSummary.summaryGroups[0].isRunning, true) assert.equal(activePrefixSummary.summaryGroups[0].isRunning, false)
assert.equal(activePrefixSummary.segments.filter(segment => segment.kind === 'unit' && segment.unit.kind === 'summary').length, 2) assert.equal(activePrefixSummary.segments.filter(segment => segment.kind === 'unit' && segment.unit.kind === 'summary').length, 1)
const initialInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{ const initialInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{
id: 'streaming-inspection', role: 'assistant', status: 'streaming', createdAt: 1, id: 'streaming-inspection', role: 'assistant', status: 'streaming', createdAt: 1,
@@ -127,27 +127,11 @@ const streamingInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{
{ id: 'inspect-src', name: 'list_dir', status: 'stream_executing', args: { path: 'src' } } { id: 'inspect-src', name: 'list_dir', status: 'stream_executing', args: { path: 'src' } }
] ]
}} />) }} />)
assert.equal((initialInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 1) assert.equal((initialInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 0, 'unfinished tail is not a summary')
assert.match(initialInspectionMarkup, /读取 接手状态-2026-10-15/) assert.equal((streamingInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 0)
assert.equal((streamingInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 1, assert.match(streamingInspectionMarkup, /已处理 1s/)
'new tool calls replace the current activity row instead of nesting under the first action') assert.match(streamingInspectionMarkup, /data-tool-name="list_dir"[^]*data-tool-name="read_file"/)
assert.match(streamingInspectionMarkup, /正在查看文件列表 src/) assert.match(streamingInspectionMarkup, /<strong>Listing project, reading h/)
assert.match(streamingInspectionMarkup, /data-activity-tool-id="inspect-src"/)
assert.match(streamingInspectionMarkup, /已处理 1s/,
'the elapsed duration starts at one second and is visible while the assistant turn is streaming')
assert.match(streamingInspectionMarkup, /cr-collapse--process cr-collapse--collapsed cr-live-event-feed/,
'the current action is an expandable event feed before the turn finishes')
assert.match(streamingInspectionMarkup, /aria-label="展开事件消息列表"/)
assert.match(streamingInspectionMarkup, /aria-controls="live-events-streaming-inspection-content"/)
assert.match(streamingInspectionMarkup, /data-event-type="reasoning"[^]*data-event-type="tool"/,
'reasoning and tool calls appear as ordered event messages')
assert.doesNotMatch(streamingInspectionMarkup, /过程消息/)
assert.match(streamingInspectionMarkup, /data-tool-name="list_dir"[^]*data-tool-name="read_file"/,
'all supported live tool details are mounted inside the independently expandable process section')
assert.doesNotMatch(streamingInspectionMarkup, /cr-collapse--summary/,
'streaming actions have no expandable parent summary')
assert.match(streamingInspectionMarkup, /<strong>Listing project, reading h/,
'deep-thinking details remain available and render Markdown instead of exposing raw markers')
const completedInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{ const completedInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{
id: 'completed-inspection', role: 'assistant', status: 'done', createdAt: 1, content: 'Done.', id: 'completed-inspection', role: 'assistant', status: 'done', createdAt: 1, content: 'Done.',
@@ -312,12 +296,8 @@ const streamingToolIntentMarkup = renderToStaticMarkup(<MessageBubble message={{
parts: [{ type: 'tool', toolId: 'list-src' }], parts: [{ type: 'tool', toolId: 'list-src' }],
tools: [{ id: 'list-src', name: 'list_dir', status: 'running', intent: 'Inspecting source layout', args: { path: 'src' }, argsRaw: '{"path":"src"}' }] tools: [{ id: 'list-src', name: 'list_dir', status: 'running', intent: 'Inspecting source layout', args: { path: 'src' }, argsRaw: '{"path":"src"}' }]
}} />) }} />)
assert.match(streamingToolIntentMarkup, /正在查看文件列表 src/, assert.doesNotMatch(streamingToolIntentMarkup, /cr-streaming-activity/, 'single live tool stays independent')
'the one live activity row uses the localized summary for the current tool call') assert.match(streamingToolIntentMarkup, /data-tool-name="list_dir"/)
const liveIntentStatusRow = streamingToolIntentMarkup.match(/<div class="cr-captured-tool cr-streaming-activity"[^]*?<\/div><\/div>/)?.[0] ?? ''
assert.ok(liveIntentStatusRow)
assert.doesNotMatch(liveIntentStatusRow, /Inspecting source layout/,
'provider English intent stays out of the operation status line')
for (const status of ['cancelled', 'error'] as const) { for (const status of ['cancelled', 'error'] as const) {
const terminalMarkup = renderToStaticMarkup(<MessageBubble message={{ const terminalMarkup = renderToStaticMarkup(<MessageBubble message={{
id: `terminal-${status}`, role: 'assistant', status, createdAt: 1, turnDurationMs: 65_000, id: `terminal-${status}`, role: 'assistant', status, createdAt: 1, turnDurationMs: 65_000,
+1 -1
View File
@@ -22,7 +22,7 @@ function check(label: string, condition: boolean, detail = ''): void {
function render(content: string, options: { streaming?: boolean; onQuote?: (markdown: string) => void } = {}): string { function render(content: string, options: { streaming?: boolean; onQuote?: (markdown: string) => void } = {}): string {
// Original OpacityText retains plain span wrappers after completion; compare textual structure without those wrappers. // Original OpacityText retains plain span wrappers after completion; compare textual structure without those wrappers.
return renderToStaticMarkup(<MarkdownContent content={content} {...options} />).replace(/<span>([^<]*)<\/span>/g, '$1') return renderToStaticMarkup(<MarkdownContent content={content} sourceId="markdown-regression-text-0" {...options} />).replace(/<span>([^<]*)<\/span>/g, '$1')
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { build } from 'esbuild'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { resolveSelectedModel } from '../src/shared/model-selection'
import type { CustomModelProfile, CustomModelInput } from '../src/shared/models'
const temp = await mkdtemp(join(tmpdir(), 'ycode-model-alignment-'))
const state = { path: join(temp, 'fresh') }
;(globalThis as any).__modelAlignment = state
let count = 0
try {
const outfile = join(temp, 'models.mjs')
await build({ entryPoints: ['src/main/models.ts'], outfile, bundle: true, platform: 'node', format: 'esm', logLevel: 'silent',
plugins: [{ name: 'offline-host', setup(b) {
b.onResolve({filter:/^electron$/},()=>({path:'host',namespace:'fixture'}))
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const app={getPath:()=>globalThis.__modelAlignment.path}; export const safeStorage={isEncryptionAvailable:()=>false};`,loader:'js'}))
b.onResolve({filter:/^\.\/model-connection$/},()=>({path:'connection',namespace:'offline'}))
b.onResolve({filter:/^\.\/model-metadata$/},()=>({path:'metadata',namespace:'offline'}))
b.onLoad({filter:/.*/,namespace:'offline'},args=>({contents:args.path==='metadata'
? `export const modelMetadata=async()=>null;`
: `export const cachedConnection=()=>undefined,cachedGeneration=()=>undefined,discoverModelConnection=async()=>undefined,modelFetch=()=>{throw Error('external network forbidden in regression')},readModelJson=()=>{},rememberGeneration=()=>{};`,loader:'js'}))
}}] })
const reload=()=>import(`${pathToFileURL(outfile).href}?${++count}`) as Promise<typeof import('../src/main/models')>
let models=await reload()
assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null},'fresh install must not synthesize deepseek')
const input=(id:string):CustomModelInput=>({id,name:id,model:id,baseUrl:'https://offline.invalid',providerId:'custom',authMode:'none',protocol:'anthropic'})
assert.equal((await models.saveModel(input('one'))).selectedModelId,'one')
assert.equal((await models.saveModel(input('two'))).selectedModelId,'two','new model becomes default')
assert.equal((await models.saveModel({...input('one'),previousId:'one'})).selectedModelId,'two','editing another model preserves selection')
const before=(await models.listModels()).models
assert.equal(resolveSelectedModel(before,'stale','two')?.id,'two')
assert.equal(resolveSelectedModel(before,'one','two')?.id,'one')
assert.equal(resolveSelectedModel(before,'stale','stale')?.id,'one')
assert.equal(resolveSelectedModel([],'one','two'),null)
assert.equal((await models.deleteModel('two')).selectedModelId,'one')
await models.deleteModel('one'); models=await reload()
assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null})
assert.equal((await models.saveModel(input('three'))).selectedModelId,'three')
state.path=join(temp,'legacy'); await mkdir(state.path)
await writeFile(join(state.path,'config.json'),JSON.stringify({model:'user-model',baseUrl:'https://configured.invalid',temperature:0.2,maxToolRounds:4}))
models=await reload(); assert.equal((await models.listModels()).models[0]?.model,'user-model','explicit legacy config preserved')
state.path=join(temp,'preferences-only');await mkdir(state.path)
await writeFile(join(state.path,'config.json'),JSON.stringify({preventSleep:true}))
models=await reload();assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null},'preferences are not a model')
state.path=join(temp,'serialized-defaults');await mkdir(state.path)
await writeFile(join(state.path,'config.json'),JSON.stringify({model:'deepseek-chat',baseUrl:'https://api.deepseek.com/v1',preventSleep:true}))
models=await reload();assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null},'old preference writes of defaults are not real configuration')
console.log('PASS model alignment: fresh/legacy migration, new/edit/default/delete/reload, valid selection (offline host fixtures)')
} finally { delete (globalThis as any).__modelAlignment; await rm(temp,{recursive:true,force:true}) }
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import { useSettingsStore } from '../src/renderer/src/stores/settings'
import { useChatStore } from '../src/renderer/src/stores/chat'
import type { CustomModelProfile } from '../src/shared/models'
const profile=(id:string):CustomModelProfile=>({id,name:id,model:id,providerId:'fixture',baseUrl:'https://offline.invalid',hasApiKey:false,keyStorage:'none'})
const session=(id:string,modelId:string)=>({id,modelId,title:id,createdAt:1,updatedAt:1})
let finish!: (value:unknown)=>void
const saved=()=>new Promise(resolve=>{finish=resolve})
const updates:Array<{id:string,patch:any}>=[]
;(globalThis as any).window={ycode:{models:{save:saved,remove:async()=>({models:[],selectedModelId:null})},session:{updateContext:async(id:string,patch:any)=>{updates.push({id,patch});return {...session(id,patch.modelId),...patch}}}}}
useSettingsStore.setState({models:[profile('a'),profile('b')],selectedModelId:'b'})
useChatStore.setState({sessions:[session('A','a'),session('B','b')],currentSessionId:'A',draftId:1,draftContext:{modelId:'a'},messages:[]})
const add=useSettingsStore.getState().saveModel(profile('c'))
useChatStore.setState({currentSessionId:'B'})
finish({models:[profile('a'),profile('b'),profile('c')],selectedModelId:'c'});await add
assert.equal(updates.length,0,'late addition must not change another active session')
assert.equal(useChatStore.getState().sessions.find(s=>s.id==='B')?.modelId,'b')
useChatStore.setState({currentSessionId:'A'})
const rename=useSettingsStore.getState().saveModel({...profile('a2'),previousId:'a'})
finish({models:[profile('a2'),profile('b'),profile('c')],selectedModelId:'c'});await rename
assert.equal(useChatStore.getState().sessions.find(s=>s.id==='A')?.modelId,'a2','rename is not deletion fallback')
assert.equal(useChatStore.getState().draftContext.modelId,'a2')
assert.equal(updates.length,0,'edit does not arbitrarily select')
const addCurrent=useSettingsStore.getState().saveModel(profile('d'))
finish({models:[profile('a2'),profile('b'),profile('c'),profile('d')],selectedModelId:'d'});await addCurrent
assert.equal(updates.at(-1)?.id,'A');assert.equal(updates.at(-1)?.patch.modelId,'d')
const snapshot={id:'history',role:'assistant' as const,content:'answer',createdAt:1,modelId:'a',modelName:'real old model'}
useChatStore.setState({messages:[snapshot]})
await useSettingsStore.getState().deleteModel('d')
assert.equal(useChatStore.getState().sessions[0].modelId,undefined)
assert.equal(useChatStore.getState().draftContext.modelId,undefined)
assert.equal(useChatStore.getState().messages[0],snapshot,'history snapshot never rewritten')
console.log('PASS model settings: deferred owner switch, rename mapping, addition selection, empty deletion, immutable history')
@@ -0,0 +1,55 @@
/** jsdom behavior only; no layout, browser pixels, SDK process or model calls. */
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
const externalRequire=createRequire(process.cwd()+'/package.json')
const {JSDOM}=externalRequire(process.env.YCODE_TEST_DOM_MODULE||'jsdom')
const dom=new JSDOM('<html><body><main id="root"></main></body></html>',{url:'http://localhost/',pretendToBeVisual:true})
for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true})
Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true})
Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true})
const React=require('react') as typeof import('react')
const {act}=React
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
const {PermissionConfirm}=require('../src/renderer/src/workbuddy/conversation/PermissionConfirm') as typeof import('../src/renderer/src/workbuddy/conversation/PermissionConfirm')
const root=createRoot(document.getElementById('root')!)
let count=0,completed=0,declined=0, resolve!:()=>void,reject!:(e:Error)=>void
let checks=0
const check=(value:unknown,message:string)=>{assert.ok(value,message);checks++}
const options=[{optionId:'once',kind:'allow_once',name:'Once'},{optionId:'session',kind:'allow_always',name:'Session'},{optionId:'deny',kind:'reject_once',name:'Reject'}]
const pending=()=>{count++;return new Promise<void>((yes,no)=>{resolve=yes;reject=no})}
let id='a',status:'pending'|'cancelled'='pending',defaultToNo=false
const render=()=>act(async()=>root.render(<PermissionConfirm pendingId={id} status={status} toolName="Bash" options={options} details={{requestKind:'permission',toolName:'Bash',allowScope:'session',defaultToNo}} resolve={pending} reject={async()=>{declined++}} onDecisionComplete={()=>{completed++}}/>))
const key=(value:string)=>document.querySelector('section')!.dispatchEvent(new KeyboardEvent('keydown',{key:value,bubbles:true,cancelable:true}))
const click=(selector:string)=>document.querySelector(selector)!.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))
async function main(){
await render()
check(document.activeElement?.getAttribute('data-option-id')==='once','normal prompt focuses first option')
await act(async()=>{key('1');key('1')})
check(count===1,'duplicate keyboard submission synchronous guard')
check(document.querySelectorAll('button[disabled]').length===3,'all choices disabled while submitting')
await act(async()=>reject(new Error('fixture failed')))
check(document.querySelector('[role="alert"]')?.textContent?.includes('fixture failed'),'failure visible')
await act(async()=>click('[data-option-id="once"]'))
check(count===2,'failed choice retries')
await act(async()=>resolve())
check(completed===1,'success completes once')
id='b';defaultToNo=true;await render()
check(document.activeElement?.textContent?.includes('Reject'),'default-to-no focuses reject')
await act(async()=>key('1'));check(count===2,'default-to-no has no one-key approve shortcut')
await act(async()=>key('Escape'));check(declined===1,'Escape rejects')
id='c';defaultToNo=false;await render();await act(async()=>click('[data-option-id="once"]'))
const oldResolve=resolve;const previous=completed
id='d';await render();await act(async()=>oldResolve())
check(completed===previous,'late result cannot complete replacement request')
check(document.querySelector('section')?.getAttribute('data-pending-id')==='d','replacement retained')
await act(async()=>click('[data-option-id="once"]'))
status='cancelled';await render();await act(async()=>resolve())
check(completed===previous,'cancelled request ignores stale success')
check(document.querySelector('section')?.textContent?.includes('Cancelled'),'cancelled remains visible')
id='e';status='pending';await render();await act(async()=>click('.conversation-intercept-card__rule-action'))
check(document.querySelector('[role="status"]')?.textContent?.includes('未提供命中规则详情'),'missing rule explanation explicit')
await act(async()=>root.unmount());dom.window.close()
console.log(`Permission intercept DOM: ${checks} checks passed (not pixel/SDK verification)`)
process.exit(0)
}
main().catch(error=>{console.error(error);process.exit(1)})
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import { PermissionConfirm } from '../src/renderer/src/workbuddy/conversation/PermissionConfirm'
const base = {pendingId:'a',toolName:'Bash',options:[{optionId:'once',name:'Once',kind:'allow_once'},{optionId:'session',name:'Session',kind:'allow_always'}],resolve:()=>{},reject:()=>{}}
const html=renderToStaticMarkup(<PermissionConfirm {...base} details={{requestKind:'permission',toolName:'Bash',command:'echo <safe>',allowScope:'session',suggestedRules:['Bash(echo safe:*)']}} />)
assert.ok(html.includes('conversation-intercept-card'))
assert.ok(!html.includes('high-credit'))
assert.ok(html.includes('echo &lt;safe&gt;'))
assert.ok(html.includes('Bash(echo safe:*)'))
assert.ok(html.includes('查看拦截规则'))
assert.ok(!html.includes('命中询问规则'))
const busy=renderToStaticMarkup(<PermissionConfirm {...base} status="responding" />)
assert.equal((busy.match(/disabled=""/g)||[]).length,3)
const unavailable=renderToStaticMarkup(<PermissionConfirm {...base} options={base.options.slice(0,1)} details={{requestKind:'permission',toolName:'Bash',allowScope:'once',sessionUnavailableReason:'Only once'}} />)
assert.ok(unavailable.includes('Only once'))
assert.ok(!unavailable.includes('data-option-id="session"'))
console.log('Permission intercept static rendering passed; interactive keyboard/focus remains UI acceptance')
@@ -0,0 +1,73 @@
/** DOM contracts only; no browser layout/pixel/REA claim. See task-popconfirm-regression for temporary jsdom setup. */
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const dom = new JSDOM('<html><body><main id="root"></main></body></html>', {pretendToBeVisual:true,url:'http://localhost/'})
for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','MouseEvent','KeyboardEvent','Event']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true})
Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true})
Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true})
const React=require('react') as typeof import('react')
const {act}=React
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating')
const {CompletedFold,LiveEventDisclosure}=require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
const root=createRoot(document.getElementById('root')!)
const click=(el:Element)=>el.dispatchEvent(new MouseEvent('click',{bubbles:true}))
const key=(el:Element,k:string,extra={})=>el.dispatchEvent(new KeyboardEvent('keydown',{key:k,bubbles:true,cancelable:true,...extra}))
const input=(value:string)=>{const el=document.querySelector('input')!; Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype,'value')!.set!.call(el,value);el.dispatchEvent(new Event('input',{bubbles:true}))}
let checks=0
const check=(condition:unknown,label:string)=>{assert.ok(condition,label); checks++}
async function main(){
let calls=0;let fail=true;let answer:any
const config={toolCallId:'q',questions:[{id:'one',question:'First',options:['A','B']},{id:'two',question:'Second',options:['X','Y'],multiSelect:true}]}
await act(async()=>root.render(<QuestionFloating config={config} onSelect={(_text,payload)=>{calls++; answer=payload;if(fail)throw Error('sync fail')}}/>))
check(document.activeElement?.getAttribute('role')==='button','first option receives focus')
await act(async()=>{key(document.activeElement!,'Enter');await new Promise(r=>setTimeout(r,140))})
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Second','keyboard single choice advances page')
await act(async()=>click(document.querySelectorAll('.question-floating__option')[1]))
await act(async()=>input('custom answer'))
await act(async()=>{click(document.querySelector('.question-floating__advance')!);click(document.querySelector('.question-floating__advance')!)})
check(calls===1,'same-tick duplicate submit locked')
check(document.querySelector('[role="alert"]')?.textContent==='sync fail','sync throw visible and retryable')
check(answer.questions[1].answers.join('|')==='Y|custom answer','multi-select custom supplement preserved')
fail=false
await act(async()=>click(document.querySelector('.question-floating__advance')!))
check(calls===2,'retry succeeds')
let reject:(e:Error)=>void=()=>{};let skipCalls=0
await act(async()=>root.render(<QuestionFloating key="skip" config={config} onSkip={()=>{skipCalls++;return new Promise<void>((_,r)=>{reject=r})}}/>))
await act(async()=>key(document.querySelector('[role="dialog"]')!,'Escape'))
check(skipCalls===1,'Escape submits skip')
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')==='true','skip pending busy')
await act(async()=>reject(Error('async fail')))
check(document.querySelector('[role="alert"]')?.textContent==='async fail','async rejection surfaced')
check(!document.querySelector<HTMLButtonElement>('.question-floating__dismiss')?.disabled,'skip failure unlocks controls')
let sends=0
await act(async()=>root.render(<QuestionFloating key="ime" config={{questions:[{id:'i',question:'Input',options:[]}]}} onSelect={()=>{sends++}}/>))
await act(async()=>input('中文'))
await act(async()=>key(document.querySelector('input')!,'Enter',{isComposing:true}))
check(sends===0,'IME Enter does not submit')
await act(async()=>key(document.querySelector('input')!,'Enter'))
check(sends===1,'plain Enter submits custom answer')
// A queued auto-advance from the old request must not move a replacement request.
await act(async()=>root.render(<QuestionFloating key="replace" config={config}/>))
await act(async()=>click(document.querySelector('.question-floating__option')!))
await act(async()=>root.render(<QuestionFloating key="replace" config={{...config,toolCallId:'new'}}/>))
await act(async()=>new Promise(r=>setTimeout(r,140)))
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='First','request replacement cancels old delayed advance')
const parts=[{type:'reasoning',id:'r',text:'thought'},{type:'tool',toolId:'t'},{type:'text',text:'A'},{type:'tool',toolId:'later'}] as const
await act(async()=>root.render(<CompletedFold parts={parts} turnFinished turnDurationMs={1000} renderUnit={(_p,i)=><i data-order={i}/>}/>))
check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','completion initially collapsed')
await act(async()=>key(document.querySelector('.cr-collapse__header')!,'Enter'))
check([...document.querySelectorAll('[data-order]')].map(n=>n.getAttribute('data-order')).join(',')==='0,1,2,3','expanded sequence preserves original order')
await act(async()=>key(document.querySelector('.cr-collapse__header')!,' '))
check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','keyboard collapses completion independently')
function Groups(){const [open,setOpen]=React.useState<Record<string,boolean>>({});return <>{['first','second'].map(id=><LiveEventDisclosure key={id} id={id} header={id} expanded={open[id]??false} onExpandedChange={value=>setOpen(previous=>({...previous,[id]:value}))}><span>{id} details</span></LiveEventDisclosure>)}</>}
await act(async()=>root.render(<Groups/>))
await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[0]))
check(document.querySelectorAll('.cr-collapse__header')[0].getAttribute('aria-expanded')==='true' && document.querySelectorAll('.cr-collapse__header')[1].getAttribute('aria-expanded')==='false','first process expansion does not expand second group')
await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[1]))
await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[0]))
check(document.querySelectorAll('.cr-collapse__header')[0].getAttribute('aria-expanded')==='false' && document.querySelectorAll('.cr-collapse__header')[1].getAttribute('aria-expanded')==='true','second group retains independent state')
await act(async()=>root.unmount());dom.window.close();console.log(`question-interaction: ${checks} DOM behavioral checks passed`);process.exit(0)
}
main().catch(error=>{console.error(error);dom.window.close();process.exit(1)})
+3 -1
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { nextRequestSettings } from '../src/main/agent/request-settings' import { nextRequestSettings } from '../src/main/agent/request-settings'
import { useChatStore } from '../src/renderer/src/stores/chat' import { useChatStore } from '../src/renderer/src/stores/chat'
import { useSettingsStore } from '../src/renderer/src/stores/settings'
import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc' import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc'
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types' import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types'
import type { SessionContext } from '../src/shared/workspaces' import type { SessionContext } from '../src/shared/workspaces'
@@ -181,6 +182,7 @@ function resetStore(
messages: ChatMessage[] = [], messages: ChatMessage[] = [],
draftContext: SessionContext = {} draftContext: SessionContext = {}
): void { ): void {
useSettingsStore.setState({ models: ['model-a','model-b','model-c','draft-a','draft-b'].map(id => ({ id, name:id, providerId:'fixture', model:id, baseUrl:'http://localhost', hasApiKey:false, keyStorage:'none' as const })), selectedModelId:'model-a' })
useChatStore.setState({ useChatStore.setState({
sessions, sessions,
currentSessionId, currentSessionId,
@@ -193,7 +195,7 @@ function resetStore(
sendingSessions: [], sendingSessions: [],
running: null, running: null,
sending: false, sending: false,
loading: false, loading: false, loadError: null,
error: null, error: null,
draftContext, draftContext,
draftId: 0 draftId: 0
+34
View File
@@ -0,0 +1,34 @@
/** Offline alignment regression: never starts a model service or Claude CLI. */
import { build } from 'esbuild'
import { spawnSync } from 'node:child_process'
const tests = [
'model-selection-alignment-regression.ts', 'model-settings-alignment-regression.ts', 'session-factory-alignment-regression.ts',
'session-composer-alignment-regression.tsx', 'alignment-conversation-regression.tsx',
'runtime-authorization-regression.ts', 'permission-intercept-regression.tsx'
]
for (const file of tests) {
const format = file.endsWith('.tsx') ? 'cjs' : 'esm'
const outfile = `.tmp/alignment-tests/${file.replace(/\.tsx?$/, format === 'cjs' ? '.cjs' : '.mjs')}`
await build({ entryPoints: [`scripts/${file}`], outfile, bundle: true, platform: 'node', format, external: ['esbuild'], jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
}
// Host/SDK-interface fixtures; these builders stub query and never launch the CLI.
for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approval-policy-regression.mjs', '.tmp/claude-grant-replay-regression.mjs']) {
const result = spawnSync(process.execPath, [file], { stdio: 'inherit', timeout: 120000 })
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
}
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
if (process.env.YCODE_TEST_DOM_MODULE) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'permission-intercept-dom-regression']) {
const outfile = `.tmp/alignment-tests/${file}.cjs`
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
}
}
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import { sessionGrantProposal, sessionGrantRules, rememberSessionGrant, clearSessionGrants } from '../src/main/agent/claude/session-grants'
import { applyAssistantEvent } from '../src/shared/message-parts'
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
import type { ChatEvent, ChatMessage } from '../src/shared/types'
const proposal = sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'projectSettings',rules:[{toolName:'Bash',ruleContent:'echo safe:*'},{toolName:'Read',ruleContent:'/tmp/owned/**'}]}])!
assert.equal(proposal.updates[0].destination, 'session')
assert.deepEqual(proposal.rules, ['Bash(echo safe:*)','Read(/tmp/owned/**)'])
assert.equal(sessionGrantProposal([{type:'setMode',mode:'bypassPermissions',destination:'session'}]), undefined)
assert.equal(sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'session',rules:[{toolName:'Bash'}]}]), undefined)
assert.equal(sessionGrantProposal([...proposal.updates,{type:'addDirectories',directories:['/'],destination:'session'}]), undefined)
rememberSessionGrant('a', proposal); rememberSessionGrant('a',proposal)
assert.equal(sessionGrantRules('a').length, 2)
assert.equal(sessionGrantRules('b').length, 0)
const copy = sessionGrantRules('a'); copy.push('Write(*)'); assert.equal(sessionGrantRules('a').length, 2)
rememberSessionGrant('a',proposal,'/workspace/a'); assert.equal(sessionGrantRules('a','/workspace/b').length,0)
clearSessionGrants('a'); assert.equal(sessionGrantRules('a').length,0)
const msg: ChatMessage = {id:'m',role:'assistant',content:'',createdAt:1,requestId:'r',status:'streaming'}
const event: ChatEvent = {runId:'r',type:'progress',progress:{source:'agent',phase:'tool',toolName:'Read'}}
const live = applyAssistantEvent(msg,event)
assert.equal(live.runtimeProgress?.toolName,'Read')
assert.equal(applyAssistantEvent(msg,{...event,runId:'other'}),msg)
for(const terminal of [{runId:'r',type:'done',content:'',tools:[]},{runId:'r',type:'cancelled'},{runId:'r',type:'error',message:'fail'}] as ChatEvent[]) {
const done = applyAssistantEvent(live,{...terminal,turnFinishedAt:100,turnDurationMs:99})
assert.equal(done.runtimeProgress,undefined); assert.equal(done.turnFinishedAt,100)
assert.equal(applyAssistantEvent(done,event),done)
}
const events: ChatEvent[]=[]
const controller = new AbortController()
const broker = createInteractionBroker('r',controller.signal,event=>events.push(event))
const response = broker.approval('tool','Run', [{optionId:'once',name:'Once',kind:'allow_once'}],undefined,undefined,{requestKind:'permission',toolName:'Bash',allowScope:'once'})
const pending = events.find(event=>event.type==='interaction')!
assert.equal(pending.type,'interaction')
if(pending.type!=='interaction') throw new Error('missing')
assert.equal(respondToInteraction('other',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false)
assert.throws(()=>respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'bad'},event=>events.push(event)))
assert.equal(respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),true)
assert.equal(respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false)
await response
const cancelled = broker.approval('tool2','Run', [{optionId:'once',name:'Once',kind:'allow_once'}])
const last = events.at(-1)!
controller.abort()
await assert.rejects(cancelled,/取消/)
if(last.type==='interaction') assert.equal(respondToInteraction('r',last.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false)
const retryController=new AbortController()
const retryEvents:ChatEvent[]=[]
const retryBroker=createInteractionBroker('retry',retryController.signal,event=>retryEvents.push(event))
let failApply=true
const waiting=retryBroker.approval('tool','Run',[{optionId:'session',name:'Session',kind:'allow_always'}],undefined,undefined,undefined,()=>{if(failApply)throw new Error('apply rejected');return Promise.resolve()})
const retryEvent=retryEvents[0];if(retryEvent.type!=='interaction')throw new Error('missing')
await assert.rejects(Promise.resolve(respondToInteraction('retry',retryEvent.interaction.id,{kind:'approval',optionId:'session'},event=>retryEvents.push(event))),/apply rejected/)
assert.equal(retryEvents.some(event=>event.type==='interaction'&&event.interaction.status==='answered'),false)
assert.equal(retryEvents.at(-1)?.type==='interaction'&&(retryEvents.at(-1) as any).interaction.status,'error')
failApply=false
assert.equal(await respondToInteraction('retry',retryEvent.interaction.id,{kind:'approval',optionId:'session'},event=>retryEvents.push(event)),true)
await waiting
const abortApply=new AbortController();const abortEvents:ChatEvent[]=[]
const abortBroker=createInteractionBroker('abort-apply',abortApply.signal,event=>abortEvents.push(event))
let unblock!:()=>void
const abortedWait=abortBroker.approval('tool','Run',[{optionId:'session',name:'Session',kind:'allow_always'}],undefined,undefined,undefined,()=>new Promise<void>(resolve=>{unblock=resolve}))
const catchAbort=assert.rejects(abortedWait,/取消/)
const abortEvent=abortEvents[0];if(abortEvent.type!=='interaction')throw new Error('missing')
const applying=respondToInteraction('abort-apply',abortEvent.interaction.id,{kind:'approval',optionId:'session'},event=>abortEvents.push(event))
assert.equal(respondToInteraction('abort-apply',abortEvent.interaction.id,{kind:'approval',optionId:'session'},event=>abortEvents.push(event)),false)
await Promise.resolve() // Let the guarded async application begin before cancelling it.
abortApply.abort();unblock();assert.equal(await applying,false);await catchAbort
assert.equal(abortEvents.some(event=>event.type==='interaction'&&event.interaction.status==='answered'),false)
console.log('Runtime progress, session-grant scope and approval lifecycle regressions passed')
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict'
import { performance } from 'node:perf_hooks'
import { renderToStaticMarkup } from 'react-dom/server'
import { useChatStore } from '../src/renderer/src/stores/chat'
import { useSettingsStore } from '../src/renderer/src/stores/settings'
import { ModeChip } from '../src/renderer/src/workbuddy/composer/ModeChip'
import { SessionLoading } from '../src/renderer/src/components/SessionLoading'
import type { ChatMessage, SessionMeta } from '../src/shared/types'
import type { SendPayload } from '../src/shared/ipc'
const model = { id: 'valid', name: 'Configured', model: 'configured', providerId: 'fixture', baseUrl: 'http://localhost', hasApiKey: false, keyStorage: 'none' as const }
const session = (id: string): SessionMeta => ({ id, title: id, modelId: model.id, createdAt: 1, updatedAt: 1 })
const message = (id: string): ChatMessage => ({ id, role: 'user', content: id, createdAt: 1 })
const deferred = <T,>() => { let resolve!: (value: T) => void; let reject!: (reason: Error) => void; const promise = new Promise<T>((a,b) => { resolve=a; reject=b }); return { promise, resolve, reject } }
let sent: SendPayload | undefined
function setup() {
useSettingsStore.setState({ models: [model], selectedModelId: model.id })
useChatStore.setState({ sessions: ['a','b','c'].map(session), currentSessionId: null, messages: [], sessionMessages: {}, runs: {}, runSequences: {}, running: null, sending: false, sendingSessions: [], loading: false, loadError: null, error: null, draftContext: {}, draftId: 0 })
sent = undefined
Object.defineProperty(globalThis, 'window', { configurable: true, value: { ycode: {
session: {
list: async () => useChatStore.getState().sessions,
messages: async (id: string) => [message(id)],
updateContext: async (id: string, patch: object) => ({ ...session(id), ...patch }),
create: async (_title: unknown, context: object) => ({ ...session('new'), ...context })
}, chat: {
activeRun: async () => null,
setSessionVisible: async () => {},
send: async (payload: SendPayload) => { sent=payload; return { runId: 'run', assistantMessageId: 'answer', userMessage: message('question'), modelId: model.id } }
}
} } })
}
async function main() {
setup()
const loading = deferred<ChatMessage[]>()
window.ycode.session.messages = () => loading.promise
const selection = useChatStore.getState().selectSession('a')
assert.equal(useChatStore.getState().currentSessionId, 'a')
assert.equal(useChatStore.getState().loading, true)
assert.equal(useChatStore.getState().messages.length, 0)
await assert.rejects(useChatStore.getState().send('no'), /加载/)
loading.resolve([]); await selection
assert.equal(useChatStore.getState().loading, false)
assert.equal(useChatStore.getState().currentSessionId, 'a', 'empty existing session stays an existing session')
let reads = 0
window.ycode.session.messages = async () => { reads++; return [] }
await useChatStore.getState().selectSession('a')
assert.equal(reads, 0, 'ready reselect does not reload or flicker')
setup()
const cached = [message('cached')]
useChatStore.setState({ sessionMessages: { b: cached } })
const pending = deferred<ChatMessage[]>()
window.ycode.session.messages = () => pending.promise
const withCache = useChatStore.getState().selectSession('b')
assert.equal(useChatStore.getState().messages, cached)
pending.reject(new Error('load failure')); await withCache
assert.equal(useChatStore.getState().loadError, 'load failure')
assert.equal(useChatStore.getState().loading, false)
await assert.rejects(useChatStore.getState().send('no'), /加载失败/)
window.ycode.session.messages = async () => [message('retried')]
await useChatStore.getState().selectSession('b')
assert.equal(useChatStore.getState().loadError, null)
assert.equal(useChatStore.getState().messages[0].content, 'retried')
setup()
const waits = { a: deferred<ChatMessage[]>(), b: deferred<ChatMessage[]>(), c: deferred<ChatMessage[]>() }
window.ycode.session.messages = id => waits[id as keyof typeof waits].promise
const a=useChatStore.getState().selectSession('a'); await Promise.resolve()
const b=useChatStore.getState().selectSession('b'); await Promise.resolve()
const c=useChatStore.getState().selectSession('c'); await Promise.resolve()
waits.c.resolve([message('c')]); await c
waits.b.resolve([message('b')]); await b
waits.a.resolve([message('a')]); await a
assert.equal(useChatStore.getState().messages[0].content, 'c')
setup()
useSettingsStore.setState({ models: [], selectedModelId: null })
await assert.rejects(useChatStore.getState().send('no model'), /配置自定义模型/)
assert.equal(sent, undefined)
useSettingsStore.setState({ models: [model], selectedModelId: model.id })
useChatStore.setState({ draftContext: { modelId: 'deleted', permissionMode: 'full-access' } })
await useChatStore.getState().send('first full access', { permissionMode: 'default-sandbox' })
assert.equal(sent?.permissionMode, 'full-access', 'saved draft is authoritative over stale UI')
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'new')?.modelId, 'valid')
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'new')?.permissionMode, 'full-access')
for (const mode of ['plan', 'ask', 'craft'] as const) {
setup()
useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), agentMode: mode, permissionMode: 'full-access' }] })
await useChatStore.getState().send('mode test', { permissionMode: 'default-sandbox' })
assert.equal(sent?.permissionMode, 'full-access', 'request carries saved preference; backend mode policy enforces plan restrictions')
assert.equal(useChatStore.getState().sessions[0].permissionMode, 'full-access', 'mode does not destroy saved preference')
}
setup()
useChatStore.setState({ currentSessionId: 'a' })
window.ycode.session.updateContext = async () => { throw new Error('save failed') }
await assert.rejects(useChatStore.getState().selectAgentMode('plan'), /save failed/)
assert.equal(useChatStore.getState().sessions[0].agentMode, undefined)
window.ycode.session.updateContext = async (id, patch) => ({ ...session(id), ...patch })
await useChatStore.getState().selectAgentMode('craft')
setup()
useChatStore.setState({ currentSessionId: 'a' })
const permissionSave = deferred<SessionMeta>()
window.ycode.session.updateContext = async () => permissionSave.promise
const saving = useChatStore.getState().selectPermissionMode('full-access')
const sending = useChatStore.getState().send('after pending permission save')
assert.equal(sent, undefined, 'send waits for in-flight permission save')
permissionSave.resolve({ ...session('a'), permissionMode: 'full-access' })
await saving; await sending
assert.equal(sent?.permissionMode, 'full-access')
setup()
useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), permissionMode: 'full-access' }] })
window.ycode.session.updateContext = async (id, patch) => ({ ...useChatStore.getState().sessions.find(item => item.id === id)!, ...patch })
await useChatStore.getState().selectAgentMode('plan')
await assert.rejects(useChatStore.getState().selectPermissionMode('default-sandbox'), /不能修改权限/)
await useChatStore.getState().selectAgentMode('ask')
await assert.rejects(useChatStore.getState().selectPermissionMode('default-sandbox'), /不能修改权限/)
await useChatStore.getState().selectAgentMode('craft')
assert.equal(useChatStore.getState().sessions[0].permissionMode, 'full-access', 'plan/ask exit restores original choice')
setup()
useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), permissionMode: 'full-access' }] })
const rejectedPermission = deferred<SessionMeta>()
window.ycode.session.updateContext = async () => rejectedPermission.promise
const failingSave = useChatStore.getState().selectPermissionMode('default-sandbox')
const saveRejected = assert.rejects(failingSave, /cannot save downgrade/)
const unsafeSend = useChatStore.getState().send('must not execute with old full access')
const sendRejected = assert.rejects(unsafeSend, /cannot save downgrade/)
rejectedPermission.reject(new Error('cannot save downgrade'))
await saveRejected; await sendRejected
assert.equal(sent, undefined, 'failed pending permission downgrade must block send')
window.ycode.session.updateContext = async (id, patch) => ({ ...session(id), ...patch })
await useChatStore.getState().selectSession('b')
await useChatStore.getState().selectModel(model.id)
await useChatStore.getState().send('other owner remains usable')
assert.equal(sent?.sessionId, 'b')
await useChatStore.getState().selectSession('a')
await assert.rejects(useChatStore.getState().send('A still blocked after B saved'), /cannot save downgrade/)
// Automatic invalid-model repair is unrelated to a failed permission choice.
useChatStore.setState(state => ({ sessions: state.sessions.map(item => item.id === 'a' ? { ...item, modelId:'deleted' } : item) }))
await assert.rejects(useChatStore.getState().send('automatic model fallback must not clear permission failure'), /cannot save downgrade/)
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, model.id)
await useChatStore.getState().selectAgentMode('plan')
await assert.rejects(useChatStore.getState().send('mode save must not clear permission failure'), /cannot save downgrade/)
await useChatStore.getState().selectAgentMode('craft')
await useChatStore.getState().selectPermissionMode('default-sandbox')
await useChatStore.getState().send('safe retry')
assert.equal(sent?.permissionMode, 'default-sandbox')
setup()
useChatStore.setState({ currentSessionId: 'a' })
const firstSave = deferred<SessionMeta>()
let contextCalls = 0
window.ycode.session.updateContext = async (id, patch) => {
contextCalls++
if (contextCalls === 1) return firstSave.promise
return { ...useChatStore.getState().sessions.find(item => item.id === id)!, ...patch }
}
const permissionFirst = useChatStore.getState().selectPermissionMode('full-access')
const modeSecond = useChatStore.getState().selectAgentMode('plan')
const afterBoth = useChatStore.getState().send('wait for permission and mode')
await Promise.resolve(); await Promise.resolve()
assert.equal(contextCalls, 1, 'mode save cannot overtake earlier permission write')
assert.equal(sent, undefined)
firstSave.resolve({ ...session('a'), permissionMode: 'full-access' })
await permissionFirst; await modeSecond; await afterBoth
assert.equal(contextCalls, 2)
assert.equal(sent?.permissionMode, 'full-access')
assert.equal(useChatStore.getState().sessions[0].agentMode, 'plan')
setup()
useChatStore.setState({ draftId: 50, draftContext: { modelId:model.id, permissionMode:'full-access' } })
const creation = deferred<SessionMeta>()
window.ycode.session.create = async () => creation.promise
const olderDraftSend = useChatStore.getState().send('old draft')
useChatStore.getState().startTask({ modelId:model.id, permissionMode:'default-sandbox' })
creation.resolve({ ...session('created-old-draft'), permissionMode:'full-access' })
await olderDraftSend
assert.equal(sent?.sessionId, 'created-old-draft')
assert.equal(sent?.permissionMode, 'full-access')
assert.equal(useChatStore.getState().currentSessionId, null)
assert.equal(useChatStore.getState().draftContext.permissionMode, 'default-sandbox', 'pending creation cannot overwrite newer draft preference')
assert.equal(renderToStaticMarkup(<ModeChip mode="craft" onClear={() => {}} />), '')
assert.match(renderToStaticMarkup(<ModeChip mode="plan" onClear={() => {}} />), /退出计划模式/)
assert.doesNotMatch(renderToStaticMarkup(<ModeChip mode="ask" disabled onClear={() => {}} />), /<button/)
const skeleton = renderToStaticMarkup(<SessionLoading loading />)
assert.equal((skeleton.match(/yc-session-skeleton__group/g) ?? []).length, 2)
assert.equal((skeleton.match(/yc-session-skeleton__line /g) ?? []).length, 6)
assert.equal(renderToStaticMarkup(<SessionLoading loading={false} />), '')
setup()
const history = Array.from({ length: 10000 }, (_, i) => message(`message-${i}`))
const json = JSON.stringify(history), before = performance.now()
const parsed = JSON.parse(json)
const parseMs = performance.now() - before
window.ycode.session.messages = async () => parsed
const start = performance.now(); await useChatStore.getState().selectSession('a')
assert.equal(useChatStore.getState().messages.length, 10000)
console.log(JSON.stringify({ historyMessages:10000, bytes:json.length, parseMs, storeHydrationMs:performance.now()-start, note:'Node parsing/store benchmark only, not browser rendering or Windows IPC performance' }))
console.log('session-composer-alignment: loading/cache/retry/races/model/permissions/modes/SSR passed')
}
void main()
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { build } from 'esbuild'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const temp = await mkdtemp(join(tmpdir(), 'ycode-factory-alignment-'))
try {
const outfile = join(temp, 'factory.mjs')
await build({entryPoints:['src/main/session-factory.ts'],outfile,bundle:true,platform:'node',format:'esm',logLevel:'silent',plugins:[{
name:'isolated-factory-ports',setup(b){
b.onResolve({filter:/^\.\/(sessions|models|workspaces)$/},args=>({path:args.path,namespace:'fixture'}))
b.onLoad({filter:/.*/,namespace:'fixture'},args=>({loader:'js',contents:args.path==='./sessions'
? 'export const createSession=async(title,context)=>({id:"fixture-session",title,...context});'
:args.path==='./models' ? 'export const listModels=async()=>({selectedModelId:"configured-model"}); export const resolveModel=async(id)=>({id});'
:'export const listWorkspaces=async()=>({selectedWorkspaceId:"workspace"});export const workspaceRoot=async()=>"/fixture";'}))
}
}]})
const { createManagedSession } = await import(pathToFileURL(outfile).href)
const projects={get:async()=>undefined}
const session=await createManagedSession(projects,'first',{permissionMode:'full-access',agentMode:'craft'})
assert.equal(session.permissionMode,'full-access','first session retains explicit full access before any turn')
assert.equal(session.modelId,'configured-model')
const plan=await createManagedSession(projects,'plan',{permissionMode:'full-access',agentMode:'plan'})
assert.equal(plan.permissionMode,'full-access','saved preference is not overwritten by effective plan restrictions')
assert.equal(plan.agentMode,'plan')
await assert.rejects(()=>createManagedSession(projects,'bad',{permissionMode:'unexpected'}),/权限模式无效/)
console.log('PASS session factory: first-send preference, mode preservation, invalid permission rejection (isolated ports)')
} finally {await rm(temp,{recursive:true,force:true})}
+1 -1
View File
@@ -106,7 +106,7 @@ const streamingMessage = (content: string, parts: ChatMessage['parts']): ChatMes
}) })
const emptyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('', [])} />) const emptyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('', [])} />)
assert.match(emptyStream, /wb-cr-streaming-footer/) assert.match(emptyStream, /wb-cr-streaming-footer/)
assert.match(emptyStream, /思考中/) assert.match(emptyStream, /思考中|Thinking/)
assert.doesNotMatch(emptyStream, /bubble__cursor/) assert.doesNotMatch(emptyStream, /bubble__cursor/)
const bodyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('partial answer', [{ type: 'text', text: 'partial answer' }])} />) const bodyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('partial answer', [{ type: 'text', text: 'partial answer' }])} />)
+98
View File
@@ -0,0 +1,98 @@
/** DOM interaction regression, no Electron sandbox bypass or upstream calls.
* npm install --prefix /tmp/ycode-popconfirm-test --cache /tmp/ycode-popconfirm-test/cache --ignore-scripts jsdom
* esbuild scripts/task-popconfirm-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --outfile=.tmp/task-popconfirm-regression.cjs
* YCODE_TEST_DOM_MODULE=/tmp/ycode-popconfirm-test/node_modules/jsdom node .tmp/task-popconfirm-regression.cjs
* jsdom verifies event/state contracts, NOT browser layout, pixels or WorkBuddy parity.
*/
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
const externalRequire = createRequire(process.cwd() + '/package.json')
const { JSDOM } = externalRequire(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const dom = new JSDOM('<!doctype html><html><body><button id="anchor">Task</button><button id="other">Other task</button><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' })
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'ShadowRoot', 'MutationObserver', 'MouseEvent', 'KeyboardEvent', 'Event', 'getComputedStyle']) Object.defineProperty(globalThis, key, { value: key === 'getComputedStyle' ? dom.window.getComputedStyle.bind(dom.window) : dom.window[key], configurable: true })
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
Object.assign(globalThis, { requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true })
// Synthetic visibility is for focus semantics only, not geometry validation.
dom.window.HTMLElement.prototype.getClientRects = function () { return [new dom.window.DOMRect(20, 20, 100, 30)] as unknown as DOMRectList }
dom.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
const React = require('react') as typeof import('react')
const { act } = React
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
const { TaskPopconfirm, taskConfirmationCopy } = require('../src/renderer/src/components/TaskPopconfirm') as typeof import('../src/renderer/src/components/TaskPopconfirm')
const { Popover } = require('../src/renderer/src/ui/Popover') as typeof import('../src/renderer/src/ui/Popover')
const root = createRoot(document.getElementById('root')!)
const anchor = document.getElementById('anchor')!
const other = document.getElementById('other')!
let checks = 0
function check(condition: unknown, message: string) { assert.ok(condition, message); checks++ }
async function settle() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)) }) }
function click(element: Element) { element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) }
async function main() {
let closes = 0, calls = 0, resolve: (() => void) | undefined, reject: ((error: Error) => void) | undefined
const close = () => { closes++; root.render(null) }
const submit = () => { calls++; return new Promise<void>((yes, no) => { resolve = yes; reject = no }) }
const render = async (kind: 'archive' | 'delete' = 'archive') => {
await act(async () => root.render(<TaskPopconfirm kind={kind} running={false} anchor={anchor} onConfirm={submit} onClose={close} />)); await settle()
}
await render()
check(!document.querySelector('.wb-modal__overlay'), 'no modal overlay')
check(document.querySelector('.task-popconfirm-source'), 'optional host class')
check(document.querySelector('.wb-popover__arrow'), 'arrow rendered')
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy')
check(document.querySelector('.task-popconfirm__compatibility')?.textContent?.includes('YCode 暂无此设置入口'), 'archive destination mismatch explicit')
const confirm = document.querySelector('.task-popconfirm__confirm')!
await act(async () => { click(confirm); click(confirm) })
check(calls === 1, 'duplicate confirmation guarded synchronously')
check(confirm.hasAttribute('disabled'), 'confirmation disabled in flight')
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })))
check(closes === 0, 'pending operation not dismissed by Escape')
await act(async () => reject!(new Error('fixture failure')))
check(document.querySelector('[role="alert"]')?.textContent === 'fixture failure', 'failure remains visible')
check(!confirm.hasAttribute('disabled'), 'failure enables retry')
await act(async () => click(confirm))
check(calls === 2, 'retry submits once')
await act(async () => resolve!())
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
await render('delete')
let underlyingClicks = 0
other.addEventListener('click', () => underlyingClicks++)
await act(async () => {
other.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }))
click(other)
})
check(underlyingClicks === 0, 'outside click is consumed before underlying task handler')
check(!document.querySelector('.task-popconfirm'), 'outside click closes')
await act(async () => click(other))
check(underlyingClicks === 1, 'only the dismissal click is consumed')
await render()
anchor.focus()
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })))
check(document.activeElement === anchor, 'Escape restores anchor focus')
await render()
await act(async () => click(document.querySelector('.task-popconfirm__confirm')!))
const closesBeforeRemoval = closes
await act(async () => anchor.remove())
await settle()
check(!document.querySelector('.task-popconfirm'), 'removed anchor closes popup')
check(closes === closesBeforeRemoval + 1, 'anchor disappearance closes even in flight')
await act(async () => resolve!())
check(closes === closesBeforeRemoval + 1, 'late completion after unmount cannot close another popup')
document.body.prepend(anchor)
await act(async () => root.render(<Popover trigger={<button id="legacy">Legacy</button>} open={false} onOpenChange={() => {}}><span>hidden</span></Popover>))
check(document.getElementById('legacy')?.getAttribute('aria-expanded') === 'false', 'legacy trigger API preserved')
check(!document.querySelector('.wb-popover'), 'closed legacy popover remains unmounted')
let legacyOutside = 0
await act(async () => root.render(<Popover trigger={<button id="legacy">Legacy</button>} open onOpenChange={() => legacyOutside++}><button>Choice</button></Popover>))
await settle()
await act(async () => { other.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })); click(other) })
check(legacyOutside === 1, 'legacy outside pointer still requests close')
check(underlyingClicks === 2, 'legacy outside click is not consumed by default')
check(!document.querySelector('.wb-popover__arrow'), 'legacy arrow remains opt-in')
await act(async () => root.unmount())
dom.window.close()
console.log(`task popconfirm DOM regression: ${checks} checks passed (jsdom, not pixel validation)`)
}
// React's scheduler MessageChannel can keep Node alive after jsdom cleanup.
main().then(() => process.exit(0), error => { console.error(error); dom.window.close(); process.exit(1) })
+17 -17
View File
@@ -22,17 +22,17 @@ if (!process.versions.electron) {
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' } const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'), const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) [__filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = '' let output = ''
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('TASK_MENU_')) process.stdout.write(bytes) }) child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('TASK_MENU_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes }) child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 150000) const timer = setTimeout(() => child.kill(), 150000)
child.once('exit', async code => { child.once('exit', async (code, signal) => {
clearTimeout(timer) clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output) await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code })) console.log(JSON.stringify({ directory, exitCode: code, signal }))
if (code) console.error(output) if (code !== 0) console.error(output)
process.exitCode = code ? 1 : 0 process.exitCode = code === 0 ? 0 : 1
}) })
})() })()
} else { } else {
@@ -163,22 +163,22 @@ if (!process.versions.electron) {
await step('archive-confirm') await step('archive-confirm')
await read(openMenu); await wait(menuSource, 'menu for archive') await read(openMenu); await wait(menuSource, 'menu for archive')
await read(clickItemByLabel('归档')) await read(clickItemByLabel('归档'))
await wait(`!!document.querySelector('.task-confirm-dialog[data-task-confirm-kind="archive"]')`, 'archive confirmation') await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="archive"]')`, 'archive confirmation')
const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`) const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务') check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
check('archive confirmation points to the in-product archive entry', archiveConfirm.desc, '确认将该任务归档吗?归档后可在侧栏「已归档」中查看并恢复。') check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
check('archive confirmation does not reintroduce removed data management', archiveConfirm.desc.includes('数据管理'), false) check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档']) check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
check('archive confirmation uses the recovered 480px shell', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [480, '24px', '24px']) check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
await shot('archive-confirm') await shot('archive-confirm')
await read(`document.querySelector('.task-confirm-dialog__cancel').click()`) await read(`document.querySelector('.task-popconfirm__cancel').click()`)
await wait(`!document.querySelector('.task-confirm-dialog')`, 'archive cancellation closes') await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
check('cancelling archive keeps the task visible', await read(`document.querySelectorAll('[data-session-id]').length`), 2) check('cancelling archive keeps the task visible', await read(`document.querySelectorAll('[data-session-id]').length`), 2)
await read(openMenu); await wait(menuSource, 'menu for archive confirm') await read(openMenu); await wait(menuSource, 'menu for archive confirm')
await read(clickItemByLabel('归档')) await read(clickItemByLabel('归档'))
await wait(`!!document.querySelector('.task-confirm-dialog__confirm')`, 'archive confirmation confirm') await wait(`!!document.querySelector('.task-popconfirm__confirm')`, 'archive confirmation confirm')
await read(`document.querySelector('.task-confirm-dialog__confirm').click()`) await read(`document.querySelector('.task-popconfirm__confirm').click()`)
await wait(`document.querySelectorAll('[data-session-id]').length===1`, 'archive completes after confirmation') await wait(`document.querySelectorAll('[data-session-id]').length===1`, 'archive completes after confirmation')
const visibleAfterArchive = await read(`document.querySelectorAll('[data-session-id]').length`) const visibleAfterArchive = await read(`document.querySelectorAll('[data-session-id]').length`)
check('archive hides exactly one row', visibleAfterArchive, 1) check('archive hides exactly one row', visibleAfterArchive, 1)
@@ -189,14 +189,14 @@ if (!process.versions.electron) {
const remainingId = await read(`window.ycode.session.list().then(list=>list[0].id)`) const remainingId = await read(`window.ycode.session.list().then(list=>list[0].id)`)
await read(openMenu); await wait(menuSource, 'menu for delete') await read(openMenu); await wait(menuSource, 'menu for delete')
await read(clickItemByLabel('删除任务')) await read(clickItemByLabel('删除任务'))
await wait(`!!document.querySelector('.task-confirm-dialog[data-task-confirm-kind="delete"]')`, 'delete confirmation') await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="delete"]')`, 'delete confirmation')
const deleteConfirm = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');return {title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,running:dialog.getAttribute('data-task-confirm-running')}})()`) const deleteConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');return {title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,running:dialog.querySelector('[data-task-confirm-kind]').getAttribute('data-task-confirm-running')}})()`)
check('delete uses the idle confirmation title', deleteConfirm.title, '删除任务?') check('delete uses the idle confirmation title', deleteConfirm.title, '删除任务?')
check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。') check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认']) check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
check('idle delete is not marked as running', deleteConfirm.running, null) check('idle delete is not marked as running', deleteConfirm.running, null)
await shot('delete-confirm') await shot('delete-confirm')
await read(`document.querySelector('.task-confirm-dialog__confirm').click()`) await read(`document.querySelector('.task-popconfirm__confirm').click()`)
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation') await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')
check('delete removes that session from the store', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(remainingId)}))`), false) check('delete removes that session from the store', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(remainingId)}))`), false)
check('delete leaves only the archived session', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 1) check('delete leaves only the archived session', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 1)
+79 -8
View File
@@ -8,12 +8,12 @@ import {
tool, tool,
type CanUseTool, type CanUseTool,
type PermissionResult, type PermissionResult,
type PermissionUpdate,
type Query, type Query,
type SDKMessage, type SDKMessage,
type SDKUserMessage type SDKUserMessage
} from '@anthropic-ai/claude-agent-sdk' } from '@anthropic-ai/claude-agent-sdk'
import { z } from 'zod' import { z } from 'zod'
import { sessionGrantProposal, sessionGrantRules, rememberSessionGrant, clearSessionGrants } from './session-grants'
import type { AgentMode, PermissionMode } from '@shared/conversation' import type { AgentMode, PermissionMode } from '@shared/conversation'
import type { ChatEvent, ContextUsageBreakdown, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types' import type { ChatEvent, ContextUsageBreakdown, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
import type { ResolvedModel } from '@shared/models' import type { ResolvedModel } from '@shared/models'
@@ -327,6 +327,7 @@ export class ClaudeKernel {
private consuming: Promise<void> | null = null private consuming: Promise<void> | null = null
private active: ActiveTurn | null = null private active: ActiveTurn | null = null
private starting = false private starting = false
private permissionUpdates: Promise<void> = Promise.resolve()
private draining: Promise<void> | null = null private draining: Promise<void> | null = null
private contextQuery: ReturnType<Query['getContextUsage']> | null = null private contextQuery: ReturnType<Query['getContextUsage']> | null = null
private lastRequestId?: string private lastRequestId?: string
@@ -394,6 +395,17 @@ export class ClaudeKernel {
canUseTool: this.canUseTool, canUseTool: this.canUseTool,
hooks: { hooks: {
PreToolUse: [{ PreToolUse: [{
hooks: [async (hook) => {
if (hook.hook_event_name !== 'PreToolUse') return {}
const input = hook.tool_input && typeof hook.tool_input === 'object' ? hook.tool_input as Record<string, unknown> : {}
// SDK allow rules can bypass canUseTool, but never this host mode boundary.
if (!this.active || this.active.request.signal.aborted || (this.agentMode !== 'craft' && !isModeAllowedCall(this.agentMode, hook.tool_name, input))) {
return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const,
permissionDecisionReason: '当前回合或对话模式不允许此操作。' } }
}
return {}
}]
}, {
matcher: 'ExitPlanMode', matcher: 'ExitPlanMode',
hooks: [async (input) => { hooks: [async (input) => {
const active = await this.waitForSegment() const active = await this.waitForSegment()
@@ -503,6 +515,20 @@ export class ClaudeKernel {
void consuming.finally(() => { void consuming.finally(() => {
if (this.consuming === consuming) this.consuming = null if (this.consuming === consuming) this.consuming = null
}) })
const grants = sessionGrantRules(this.options.sessionId, this.options.workspaceRoot)
if (grants.length) {
try { await instance.applyFlagSettings({ permissions: { allow: grants } }) }
catch (error) {
// Fail closed: no prompt is queued on a kernel whose replay was not acknowledged.
instance.close()
if (this.instance === instance) { this.instance = null; this.input = null; this.processAbort = null }
// A new run must not race the old consumer's terminal cleanup.
const draining = consuming.catch(() => undefined)
this.draining = draining
void draining.finally(() => { if (this.draining === draining) this.draining = null })
throw new Error(`恢复本会话授权失败,未执行请求:${error instanceof Error ? error.message : String(error)}`)
}
}
} }
private async consume(instance: Query): Promise<void> { private async consume(instance: Query): Promise<void> {
@@ -540,13 +566,14 @@ export class ClaudeKernel {
} }
return { behavior: 'deny', message: '计划已显示在 YCode 中,请切换到 craft 后开始实施。' } return { behavior: 'deny', message: '计划已显示在 YCode 中,请切换到 craft 后开始实施。' }
} }
if (this.agentMode === 'ask' && !isReadOnlyCall(toolName, input)) { if (this.agentMode === 'ask' && !isModeAllowedCall('ask', toolName, input)) {
return { behavior: 'deny', message: 'Ask 模式只允许读取资料、搜索和提问,不能修改文件或执行命令。' } return { behavior: 'deny', message: 'Ask 模式只允许读取资料、搜索和提问,不能修改文件或执行命令。' }
} }
if (this.agentMode === 'plan' && !isReadOnlyCall(toolName, input)) { if (this.agentMode === 'plan' && !isModeAllowedCall('plan', toolName, input)) {
return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' } return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' }
} }
if (toolName === 'AskUserQuestion') { if (toolName === 'AskUserQuestion') {
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } })
const questions = questionsFromInput(input) const questions = questionsFromInput(input)
if (!questions.length) return { behavior: 'deny', message: '问题内容为空。' } if (!questions.length) return { behavior: 'deny', message: '问题内容为空。' }
const response = await active.request.interactions.question( const response = await active.request.interactions.question(
@@ -562,33 +589,67 @@ export class ClaudeKernel {
for (const answer of response.answer.questions) { for (const answer of response.answer.questions) {
answers[questionText.get(answer.id) ?? answer.id] = answer.answers.join(', ') answers[questionText.get(answer.id) ?? answer.id] = answer.answers.join(', ')
} }
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
return { behavior: 'allow', updatedInput: { ...input, answers } } return { behavior: 'allow', updatedInput: { ...input, answers } }
} }
if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) { if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) {
return { behavior: 'allow', updatedInput: input } return { behavior: 'allow', updatedInput: input }
} }
const grant = !options.suppressAlwaysAllowRule ? sessionGrantProposal(options.suggestions) : undefined
const optionsForApproval = [ const optionsForApproval = [
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' }, { optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
...(!options.suppressAlwaysAllowRule && options.suggestions?.length ...(grant
? [{ optionId: 'allow-always', name: '始终允许', kind: 'allow_always' }] ? [{ optionId: 'allow-always', name: '本次会话内始终允许', kind: 'allow_always' }]
: []), : []),
{ optionId: 'reject-once', name: '拒绝', kind: 'reject_once' } { optionId: 'reject-once', name: '拒绝', kind: 'reject_once' }
] ]
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-approval', toolName } })
const response = await active.request.interactions.approval( const response = await active.request.interactions.approval(
options.toolUseID, options.toolUseID,
options.title ?? options.displayName ?? `运行 ${toolName}`, options.title ?? options.displayName ?? `运行 ${toolName}`,
optionsForApproval, optionsForApproval,
options.description, options.description,
options.signal options.signal,
{ requestKind: options.matchedAskRule ? 'rule-intercept' : 'permission', toolName,
...(options.matchedAskRule ? { rule: { description: `${options.matchedAskRule.source}: ${options.matchedAskRule.toolName}${options.matchedAskRule.ruleContent ? `(${options.matchedAskRule.ruleContent})` : ''}` } } : {}),
...(typeof input.command === 'string' ? { command: input.command } : {}),
...(typeof input.file_path === 'string' ? { target: input.file_path } : {}),
...(options.decisionReason || options.description ? { reason: options.decisionReason || options.description } : {}),
defaultToNo: options.defaultToNo,
...(grant ? { suggestedRules: grant.rules } : options.suggestions?.length ? { sessionUnavailableReason: '当前建议包含无法安全恢复的授权类型,仅支持允许一次。' } : {}),
allowScope: optionsForApproval.some(option => option.kind === 'allow_always') ? 'session' : 'once' },
async decision => {
if (decision.kind !== 'approval' || decision.optionId !== 'allow-always') return
if (!grant) throw new Error('会话授权不可用。')
const apply = async (): Promise<void> => {
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) throw abortError()
const instance = this.instance
if (!instance) throw new Error('内核已断开,授权未应用。')
const rules = [...new Set([...sessionGrantRules(this.options.sessionId, this.options.workspaceRoot), ...grant.rules])]
await instance.applyFlagSettings({ permissions: { allow: rules } })
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) {
instance.close()
throw abortError()
}
rememberSessionGrant(this.options.sessionId, grant, this.options.workspaceRoot)
}
const applying = this.permissionUpdates.then(apply)
this.permissionUpdates = applying.catch(() => undefined)
await applying
}
) )
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
if (response.kind !== 'approval' || response.optionId === 'reject-once') { if (response.kind !== 'approval' || response.optionId === 'reject-once') {
return { behavior: 'deny', message: '用户拒绝了此操作。' } return { behavior: 'deny', message: '用户拒绝了此操作。' }
} }
if (response.optionId === 'allow-always' && !grant) return { behavior: 'deny', message: '会话授权不可用。' }
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'tool', toolName } })
return { return {
behavior: 'allow', behavior: 'allow',
updatedInput: input, updatedInput: input,
...(response.optionId === 'allow-always' && options.suggestions?.length ...(response.optionId === 'allow-always' && grant
? { updatedPermissions: options.suggestions as PermissionUpdate[] } ? { updatedPermissions: grant.updates }
: {}) : {})
} }
} catch (error) { } catch (error) {
@@ -654,6 +715,7 @@ export class ClaudeKernel {
if (!active) return if (!active) return
const index = typeof event.index === 'number' ? event.index : 0 const index = typeof event.index === 'number' ? event.index : 0
if (event.type === 'message_start') { if (event.type === 'message_start') {
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
active.assistantSequence++ active.assistantSequence++
const message = event.message as Record<string, unknown> | undefined const message = event.message as Record<string, unknown> | undefined
active.nativeUsage = message?.usage && typeof message.usage === 'object' ? message.usage as Record<string, unknown> : undefined active.nativeUsage = message?.usage && typeof message.usage === 'object' ? message.usage as Record<string, unknown> : undefined
@@ -734,6 +796,7 @@ export class ClaudeKernel {
argsRaw: JSON.stringify(item.input ?? {}), argsRaw: JSON.stringify(item.input ?? {}),
status: 'running' status: 'running'
} }
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'tool', toolName: item.name } })
active.tools.set(item.id, record) active.tools.set(item.id, record)
active.toolOrder.push(record) active.toolOrder.push(record)
active.request.emit({ runId: active.request.runId, type: 'tool_call', call: { ...record } }) active.request.emit({ runId: active.request.runId, type: 'tool_call', call: { ...record } })
@@ -769,6 +832,7 @@ export class ClaudeKernel {
if (value.type === 'result') { if (value.type === 'result') {
const active = this.active const active = this.active
if (!active) return if (!active) return
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'finishing' } })
// A result aggregates multiple API calls; it is not the last request's context size. // A result aggregates multiple API calls; it is not the last request's context size.
const usage = active.streamUsage const usage = active.streamUsage
if (usage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage }) if (usage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
@@ -891,6 +955,7 @@ export class ClaudeKernel {
originalReject(error) originalReject(error)
} }
this.active = active this.active = active
request.emit({ runId: request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
this.lastRequestId = request.runId this.lastRequestId = request.runId
this.lastGroupKey = request.userMessageId this.lastGroupKey = request.userMessageId
try { try {
@@ -1037,6 +1102,11 @@ function mustAskInFullAccess(options: Parameters<CanUseTool>[2]): boolean {
(raw.decision_reason_code != null && raw.decision_reason_code !== '') (raw.decision_reason_code != null && raw.decision_reason_code !== '')
} }
function isModeAllowedCall(mode: AgentMode, toolName: string, input: Record<string, unknown>): boolean {
if (mode === 'ask' && ['TodoWrite', 'EnterPlanMode'].includes(toolName)) return false
return isReadOnlyCall(toolName, input)
}
function isReadOnlyCall(toolName: string, input: Record<string, unknown>): boolean { function isReadOnlyCall(toolName: string, input: Record<string, unknown>): boolean {
if (READ_ONLY_TOOLS.has(toolName)) return true if (READ_ONLY_TOOLS.has(toolName)) return true
if (['mcp__ycode_capabilities__list_capabilities', 'mcp__ycode_capabilities__load_skill', 'mcp__ycode_capabilities__read_skill_resource'].includes(toolName)) return true if (['mcp__ycode_capabilities__list_capabilities', 'mcp__ycode_capabilities__load_skill', 'mcp__ycode_capabilities__read_skill_resource'].includes(toolName)) return true
@@ -1116,6 +1186,7 @@ export async function disposeClaudeKernels(): Promise<void> {
shuttingDown = true shuttingDown = true
const entries = [...kernels.values()] const entries = [...kernels.values()]
kernels.clear() kernels.clear()
clearSessionGrants()
sessionViews.clear() sessionViews.clear()
await Promise.all(entries.map(({ kernel }) => kernel.close().catch(() => undefined))) await Promise.all(entries.map(({ kernel }) => kernel.close().catch(() => undefined)))
} }
+33
View File
@@ -0,0 +1,33 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
/** Process-lifetime YCode session scope. Never persisted to user/project settings. */
const sessions = new Map<string, string[]>()
export interface SessionGrantProposal { updates: PermissionUpdate[]; rules: string[] }
/** Preserve the ENTIRE suggested rule group or offer once only; never partial grants.
* Only scoped additive allow rules can be replayed losslessly through SDK flag settings.
* Mode/directory changes and unscoped tool grants deliberately require another approval.
*/
export function sessionGrantProposal(updates: PermissionUpdate[] | undefined): SessionGrantProposal | undefined {
if (!updates?.length) return undefined
const rules: string[] = []
const normalized: PermissionUpdate[] = []
for (const update of updates) {
if (update.type !== 'addRules' || update.behavior !== 'allow' || !update.rules.length) return undefined
for (const rule of update.rules) {
if (!/^[A-Za-z0-9_-]+$/.test(rule.toolName) || !rule.ruleContent?.trim() || /[\r\n\0]/.test(rule.ruleContent)) return undefined
rules.push(`${rule.toolName}(${rule.ruleContent})`)
}
normalized.push({ ...structuredClone(update), destination: 'session' })
}
return { updates: normalized, rules: [...new Set(rules)] }
}
function scopeKey(sessionId: string, workspaceRoot: string): string { return JSON.stringify([sessionId, workspaceRoot]) }
export function rememberSessionGrant(sessionId: string, proposal: SessionGrantProposal, workspaceRoot = ''): void {
const key = scopeKey(sessionId, workspaceRoot)
sessions.set(key, [...new Set([...(sessions.get(key) ?? []), ...proposal.rules])])
}
export function sessionGrantRules(sessionId: string, workspaceRoot = ''): string[] { return [...(sessions.get(scopeKey(sessionId, workspaceRoot)) ?? [])] }
export function clearSessionGrants(sessionId?: string): void {
if (sessionId) { for (const key of sessions.keys()) if (JSON.parse(key)[0] === sessionId) sessions.delete(key) } else sessions.clear()
}
+5 -4
View File
@@ -237,7 +237,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
const createCheckpoint = () => createMessageCheckpoint( const createCheckpoint = () => createMessageCheckpoint(
(message) => sessions.patchMessage(sessionId, message.id, { (message) => sessions.patchMessage(sessionId, message.id, {
content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions, content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions,
status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, turnFinishedAt: message.turnFinishedAt,
contextUsage: message.contextUsage, contextBreakdown: message.contextBreakdown, error: message.error, contextUsage: message.contextUsage, contextBreakdown: message.contextBreakdown, error: message.error,
claudeEntryId: message.claudeEntryId claudeEntryId: message.claudeEntryId
}), }),
@@ -249,7 +249,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
const emit = (event: ChatEvent): void => { const emit = (event: ChatEvent): void => {
const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error' const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
let observedEvent: ChatEvent = isTerminal let observedEvent: ChatEvent = isTerminal
? { ...event, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) } ? { ...event, sequence: ++eventSequence, turnFinishedAt: Date.now(), turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) }
: { ...event, sequence: ++eventSequence } : { ...event, sequence: ++eventSequence }
if (observedEvent.type === 'usage') { if (observedEvent.type === 'usage') {
observedEvent = { ...observedEvent, contextWindow: contextWindowAtStart, source: 'upstream', sampledAt: Date.now(), modelId: assistant.modelId } observedEvent = { ...observedEvent, contextWindow: contextWindowAtStart, source: 'upstream', sampledAt: Date.now(), modelId: assistant.modelId }
@@ -274,6 +274,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
const task = Promise.resolve().then(async () => { const task = Promise.resolve().then(async () => {
try { try {
emit({ runId, type: 'progress', progress: { source: 'preparation', phase: 'preparing' } })
await sessions.setClaudeFork(sessionId, forkAt, forceNew) await sessions.setClaudeFork(sessionId, forkAt, forceNew)
if (payload.action) await releaseClaudeKernel(sessionId, true) if (payload.action) await releaseClaudeKernel(sessionId, true)
const kernel = await getClaudeKernel({ const kernel = await getClaudeKernel({
@@ -326,7 +327,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id, emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id,
onSegmentComplete: async result => { onSegmentComplete: async result => {
assistant = { ...assistant, content: result.content, tools: result.tools, claudeEntryId: result.entryId, assistant = { ...assistant, content: result.content, tools: result.tools, claudeEntryId: result.entryId,
status: 'done', turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt), turnStartedAt: undefined } status: 'done', runtimeProgress: undefined, turnFinishedAt: Date.now(), turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt), turnStartedAt: undefined }
betweenSegments = true betweenSegments = true
await checkpoint.finish(assistant) await checkpoint.finish(assistant)
}, },
@@ -371,7 +372,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
controller.abort() controller.abort()
try { try {
if (terminalEvent) { if (terminalEvent) {
terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) } terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnFinishedAt: terminalEvent.turnFinishedAt ?? Date.now(), turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) }
if (!betweenSegments) assistant = applyAssistantEvent(assistant, terminalEvent) if (!betweenSegments) assistant = applyAssistantEvent(assistant, terminalEvent)
} }
await checkpoint.finish(assistant) await checkpoint.finish(assistant)
+18
View File
@@ -77,6 +77,24 @@ function decryptKey(stored: StoredConfig): { key: string; storage: 'encrypted' |
return { key: '', storage: 'none' } return { key: '', storage: 'none' }
} }
/** Only migrate explicitly stored legacy model settings, never in-memory defaults. */
export async function getLegacyModelConfig(): Promise<GatewayConfig | null> {
let raw: string
try { raw = await fs.readFile(filePath(), 'utf8') }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error }
const stored = JSON.parse(raw) as Partial<StoredConfig> | null
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) throw new Error('旧配置文件内容无效')
if (typeof stored.model !== 'string' || !stored.model.trim()
|| typeof stored.baseUrl !== 'string' || !stored.baseUrl.trim()) return null
// Older preference writes also serialized DEFAULTS; without credentials they are not a configured model.
if (stored.model === DEFAULTS.model && stored.baseUrl === DEFAULTS.baseUrl && !stored.apiKeyEnc && !stored.apiKeyPlain) return null
const complete = { ...DEFAULTS, ...stored } as StoredConfig
const { key } = decryptKey(complete)
return { baseUrl: complete.baseUrl, model: complete.model, apiKey: key,
temperature: complete.temperature, maxToolRounds: complete.maxToolRounds,
speechBaseUrl: complete.speechBaseUrl, speechModel: complete.speechModel }
}
/** 供主进程内部使用:拿到可直接请求上游的完整配置 */ /** 供主进程内部使用:拿到可直接请求上游的完整配置 */
export async function getGatewayConfig(): Promise<GatewayConfig> { export async function getGatewayConfig(): Promise<GatewayConfig> {
const stored = await load() const stored = await load()
+23 -8
View File
@@ -1,14 +1,14 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import type { ChatInteraction, InteractionResponse, PermissionOption, QuestionItem } from '@shared/conversation' import type { ApprovalDetails, ChatInteraction, InteractionResponse, PermissionOption, QuestionItem } from '@shared/conversation'
import type { ChatEvent } from '@shared/types' import type { ChatEvent } from '@shared/types'
interface PendingInteraction { request: ChatInteraction; resolve(value: InteractionResponse): void; reject(error: Error): void; detach(): void } interface PendingInteraction { applying?: boolean; beforeResolve?: (response: InteractionResponse) => Promise<void>; request: ChatInteraction; resolve(value: InteractionResponse): void; reject(error: Error): void; detach(): void }
const pending = new Map<string, PendingInteraction>() const pending = new Map<string, PendingInteraction>()
function abortError(): Error { return Object.assign(new Error('交互已取消'), { name: 'AbortError' }) } function abortError(): Error { return Object.assign(new Error('交互已取消'), { name: 'AbortError' }) }
export function createInteractionBroker(runId: string, signal: AbortSignal, emit: (event: ChatEvent) => void) { export function createInteractionBroker(runId: string, signal: AbortSignal, emit: (event: ChatEvent) => void) {
async function request(input: ChatInteraction, cancellation?: AbortSignal): Promise<InteractionResponse> { async function request(input: ChatInteraction, cancellation?: AbortSignal, beforeResolve?: (response: InteractionResponse) => Promise<void>): Promise<InteractionResponse> {
const activeSignal = cancellation ? AbortSignal.any([signal, cancellation]) : signal const activeSignal = cancellation ? AbortSignal.any([signal, cancellation]) : signal
if (activeSignal.aborted) throw abortError() if (activeSignal.aborted) throw abortError()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -20,7 +20,7 @@ export function createInteractionBroker(runId: string, signal: AbortSignal, emit
emit({ runId, type: 'interaction', interaction: { ...input, status: 'cancelled' } }) emit({ runId, type: 'interaction', interaction: { ...input, status: 'cancelled' } })
reject(abortError()) reject(abortError())
} }
pending.set(input.id, { request: input, resolve, reject, detach: () => activeSignal.removeEventListener('abort', onAbort) }) pending.set(input.id, { beforeResolve, request: input, resolve, reject, detach: () => activeSignal.removeEventListener('abort', onAbort) })
activeSignal.addEventListener('abort', onAbort, { once: true }) activeSignal.addEventListener('abort', onAbort, { once: true })
emit({ runId, type: 'interaction', interaction: input }) emit({ runId, type: 'interaction', interaction: input })
}) })
@@ -34,9 +34,9 @@ export function createInteractionBroker(runId: string, signal: AbortSignal, emit
if (response.kind !== 'question') throw new Error('问答响应类型不匹配') if (response.kind !== 'question') throw new Error('问答响应类型不匹配')
return response return response
}, },
async approval(toolCallId: string, title: string, options: PermissionOption[], scope?: string, cancellation?: AbortSignal) { async approval(toolCallId: string, title: string, options: PermissionOption[], scope?: string, cancellation?: AbortSignal, details?: ApprovalDetails, beforeResolve?: (response: InteractionResponse) => Promise<void>) {
const id = randomUUID() const id = randomUUID()
const response = await request({ id, requestId: id, runId, toolCallId, kind: 'approval', title, options, scope, status: 'pending' }, cancellation) const response = await request({ id, requestId: id, runId, toolCallId, kind: 'approval', title, options, scope, details, status: 'pending' }, cancellation, beforeResolve)
if (response.kind !== 'approval') throw new Error('审批响应类型不匹配') if (response.kind !== 'approval') throw new Error('审批响应类型不匹配')
return response return response
} }
@@ -45,9 +45,9 @@ export function createInteractionBroker(runId: string, signal: AbortSignal, emit
export type InteractionBroker = ReturnType<typeof createInteractionBroker> export type InteractionBroker = ReturnType<typeof createInteractionBroker>
/** Exact run/id binding makes stale or duplicated button submissions harmless. */ /** Exact run/id binding makes stale or duplicated button submissions harmless. */
export function respondToInteraction(runId: string, id: string, response: InteractionResponse, emit: (event: ChatEvent) => void): boolean { export function respondToInteraction(runId: string, id: string, response: InteractionResponse, emit: (event: ChatEvent) => void): boolean | Promise<boolean> {
const entry = pending.get(id) const entry = pending.get(id)
if (!entry || entry.request.runId !== runId) return false if (!entry || entry.applying || entry.request.runId !== runId) return false
const request = entry.request const request = entry.request
if (request.kind !== response.kind) throw new Error('交互响应类型不匹配') if (request.kind !== response.kind) throw new Error('交互响应类型不匹配')
let resolved: ChatInteraction let resolved: ChatInteraction
@@ -73,9 +73,24 @@ export function respondToInteraction(runId: string, id: string, response: Intera
finalResponse = { ...response, answer } finalResponse = { ...response, answer }
resolved = { ...request, answer, skipped: response.skipped, status: 'answered' } resolved = { ...request, answer, skipped: response.skipped, status: 'answered' }
} else throw new Error('交互响应类型不匹配') } else throw new Error('交互响应类型不匹配')
const finish = (): boolean => {
if (pending.get(id) !== entry) return false
pending.delete(id) pending.delete(id)
entry.detach() entry.detach()
emit({ runId, type: 'interaction', interaction: resolved }) emit({ runId, type: 'interaction', interaction: resolved })
entry.resolve(finalResponse) entry.resolve(finalResponse)
return true return true
} }
if (entry.beforeResolve) {
entry.applying = true
emit({ runId, type: 'interaction', interaction: { ...request, status: 'responding' } })
return Promise.resolve().then(() => entry.beforeResolve!(finalResponse)).then(finish, error => {
if (pending.get(id) === entry) {
entry.applying = false
emit({ runId, type: 'interaction', interaction: { ...request, status: 'error', error: error instanceof Error ? error.message : String(error) } })
}
throw error
})
}
return finish()
}
+2 -2
View File
@@ -306,9 +306,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
return readWorkspaceResource(dirname(attachment.path!), attachment.name) return readWorkspaceResource(dirname(attachment.path!), attachment.name)
}) })
ipcMain.handle('speech:transcribe', async (_event, payload: { audioBase64: string; mimeType: string }) => transcribeAudio(await config.getGatewayConfig(), payload)) ipcMain.handle('speech:transcribe', async (_event, payload: { audioBase64: string; mimeType: string }) => transcribeAudio(await config.getGatewayConfig(), payload))
ipcMain.handle('chat:respond', (_event, runId: string, interactionId: string, response: InteractionResponse) => { ipcMain.handle('chat:respond', async (_event, runId: string, interactionId: string, response: InteractionResponse) => {
const emit = runEmitters.get(runId) const emit = runEmitters.get(runId)
return { accepted: !!emit && respondToInteraction(runId, interactionId, response, emit) } return { accepted: !!emit && await respondToInteraction(runId, interactionId, response, emit) }
}) })
const saveDownload = async (payload: DownloadPayload): Promise<DownloadResult> => { const saveDownload = async (payload: DownloadPayload): Promise<DownloadResult> => {
const { filename, bytes } = decodeDownload(payload) const { filename, bytes } = decodeDownload(payload)
+8 -3
View File
@@ -9,7 +9,7 @@ import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpo
import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection' import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection'
import { modelMetadata } from './model-metadata' import { modelMetadata } from './model-metadata'
import { resolveModelTokenLimits } from '@shared/model-token-limits' import { resolveModelTokenLimits } from '@shared/model-token-limits'
import { getGatewayConfig } from './config' import { getGatewayConfig, getLegacyModelConfig } from './config'
interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> { interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
apiKeyEnc?: string apiKeyEnc?: string
@@ -75,7 +75,12 @@ async function readRegistry(persistMigration: boolean): Promise<StoredRegistry>
try { raw = await fs.readFile(filePath(), 'utf8') } try { raw = await fs.readFile(filePath(), 'utf8') }
catch (error) { catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const legacy = await getGatewayConfig() const legacy = await getLegacyModelConfig()
if (!legacy) {
const initial: StoredRegistry = { version: 1, models: [], selectedModelId: null }
if (persistMigration) await persist(initial)
return initial
}
const migrated: StoredModel = { const migrated: StoredModel = {
id: 'legacy-default', name: legacy.model, providerId: 'custom', id: 'legacy-default', name: legacy.model, providerId: 'custom',
model: legacy.model, baseUrl: legacy.baseUrl, authMode: 'api-key', protocol: 'anthropic', model: legacy.model, baseUrl: legacy.baseUrl, authMode: 'api-key', protocol: 'anthropic',
@@ -230,7 +235,7 @@ export function saveModel(input: CustomModelInput, overwrite = false): Promise<M
const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id) const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id)
const oldIndex = registry.models.findIndex(model => model.id === previousId || model.id === next.id) const oldIndex = registry.models.findIndex(model => model.id === previousId || model.id === next.id)
remaining.splice(oldIndex < 0 ? remaining.length : Math.min(oldIndex, remaining.length), 0, next) remaining.splice(oldIndex < 0 ? remaining.length : Math.min(oldIndex, remaining.length), 0, next)
const selectedModelId = !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId const selectedModelId = (!existing && !target) || !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId
const updated: StoredRegistry = { version: 1, models: remaining, selectedModelId } const updated: StoredRegistry = { version: 1, models: remaining, selectedModelId }
await persist(updated) await persist(updated)
return registryView(updated) return registryView(updated)
+2 -1
View File
@@ -7,11 +7,12 @@ import * as workspaces from './workspaces'
import { agentModeContextPatch, thinkingLevel } from './agent/mode-policy' import { agentModeContextPatch, thinkingLevel } from './agent/mode-policy'
export async function createManagedSession(projects: ProjectRepository, title?: string, context: SessionContext = {}) { export async function createManagedSession(projects: ProjectRepository, title?: string, context: SessionContext = {}) {
context = agentModeContextPatch({}, context) context = agentModeContextPatch({}, context)
if (context.permissionMode !== undefined && context.permissionMode !== 'default-sandbox' && context.permissionMode !== 'full-access') throw new Error('权限模式无效')
const project = context.projectId ? await projects.get(context.projectId) : undefined const project = context.projectId ? await projects.get(context.projectId) : undefined
if (project?.workspaceId && context.workspaceId && context.workspaceId !== project.workspaceId) throw new Error('项目任务必须使用项目所属工作空间') if (project?.workspaceId && context.workspaceId && context.workspaceId !== project.workspaceId) throw new Error('项目任务必须使用项目所属工作空间')
const workspaceId = project?.workspaceId ?? context.workspaceId ?? (await workspaces.listWorkspaces()).selectedWorkspaceId ?? undefined const workspaceId = project?.workspaceId ?? context.workspaceId ?? (await workspaces.listWorkspaces()).selectedWorkspaceId ?? undefined
const cwd = await workspaces.workspaceRoot(workspaceId) const cwd = await workspaces.workspaceRoot(workspaceId)
const modelId = context.modelId ?? (await models.listModels()).selectedModelId ?? undefined const modelId = context.modelId ?? (await models.listModels()).selectedModelId ?? undefined
if (modelId) await models.resolveModel(modelId) if (modelId) await models.resolveModel(modelId)
return sessions.createSession(title, { workspaceId, projectId: context.projectId, cwd, modelId, agentMode: context.agentMode, ...(context.thinkingLevel !== undefined ? { thinkingLevel: thinkingLevel(context.thinkingLevel) } : {}), ...(context.expertId !== undefined ? { expertId: context.expertId } : {}) }) return sessions.createSession(title, { permissionMode: context.permissionMode, workspaceId, projectId: context.projectId, cwd, modelId, agentMode: context.agentMode, ...(context.thinkingLevel !== undefined ? { thinkingLevel: thinkingLevel(context.thinkingLevel) } : {}), ...(context.expertId !== undefined ? { expertId: context.expertId } : {}) })
} }
@@ -16,7 +16,7 @@ export function formatMessageTime(time: number | null | undefined, now = new Dat
return `${year}${month}月${day}日 ${hoursMinutes}` return `${year}${month}月${day}日 ${hoursMinutes}`
} }
function MessageTime({ time }: { time: number }): React.JSX.Element | null { function MessageTime({ time }: { time: number | undefined }): React.JSX.Element | null {
const formatted = formatMessageTime(time) const formatted = formatMessageTime(time)
return formatted ? <span className="_message_time_tip_1p6pj_1 cb-message-time-tip">{formatted}</span> : null return formatted ? <span className="_message_time_tip_1p6pj_1 cb-message-time-tip">{formatted}</span> : null
} }
@@ -29,6 +29,6 @@ export function AssistantMessageDetails({ message }: { message: ChatMessage }):
<span className="_modelName_1q2ou_90">{message.modelName}</span> <span className="_modelName_1q2ou_90">{message.modelName}</span>
</span> </span>
</span>} </span>}
<MessageTime time={message.createdAt} /> <MessageTime time={message.turnFinishedAt ?? (message.turnStartedAt !== undefined && message.turnDurationMs !== undefined ? message.turnStartedAt + message.turnDurationMs : undefined)} />
</span> </span>
} }
+34 -31
View File
@@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import MessageBubble, { planMarkdown } from './MessageBubble' import MessageBubble, { planMarkdown } from './MessageBubble'
import { useChatStore } from '../stores/chat' import { useChatStore } from '../stores/chat'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { resolveSelectedModel } from '@shared/model-selection'
import { ModeChip } from '../workbuddy/composer/ModeChip'
import { SessionLoading } from './SessionLoading'
import './session-loading.css'
import SourceHome, { type SourceHomeMode } from './SourceHome' import SourceHome, { type SourceHomeMode } from './SourceHome'
import SourceChatHeader from './SourceChatHeader' import SourceChatHeader from './SourceChatHeader'
import { useConversationSearch } from '../workbuddy/conversation/useConversationSearch' import { useConversationSearch } from '../workbuddy/conversation/useConversationSearch'
@@ -97,6 +101,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const messages = useChatStore((s) => s.messages) const messages = useChatStore((s) => s.messages)
const running = useChatStore((s) => s.running) const running = useChatStore((s) => s.running)
const error = useChatStore((s) => s.error) const error = useChatStore((s) => s.error)
const loading = useChatStore((s) => s.loading)
const loadError = useChatStore((s) => s.loadError)
const sending = useChatStore((s) => s.sending) const sending = useChatStore((s) => s.sending)
const sessions = useChatStore((s) => s.sessions) const sessions = useChatStore((s) => s.sessions)
const currentId = useChatStore((s) => s.currentSessionId) const currentId = useChatStore((s) => s.currentSessionId)
@@ -108,13 +114,11 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const respond = useChatStore((s) => s.respond) const respond = useChatStore((s) => s.respond)
const revert = useChatStore((s) => s.revert) const revert = useChatStore((s) => s.revert)
const feedback = useChatStore((s) => s.feedback) const feedback = useChatStore((s) => s.feedback)
const config = useSettingsStore((s) => s.config)
const models = useSettingsStore((s) => s.models) const models = useSettingsStore((s) => s.models)
const selectedModelId = useSettingsStore((s) => s.selectedModelId) const selectedModelId = useSettingsStore((s) => s.selectedModelId)
const workspaces = useSettingsStore((s) => s.workspaces) const workspaces = useSettingsStore((s) => s.workspaces)
const selectedWorkspaceId = useSettingsStore((s) => s.selectedWorkspaceId) const selectedWorkspaceId = useSettingsStore((s) => s.selectedWorkspaceId)
const defaultWorkspaceRoot = useSettingsStore((s) => s.workspace) const defaultWorkspaceRoot = useSettingsStore((s) => s.workspace)
const [permissionMode, setPermissionMode] = useState<PermissionMode>(initialPermissionMode)
useEffect(() => { useEffect(() => {
if (!currentId) return if (!currentId) return
@@ -207,9 +211,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const effectivePermissionMode = currentId const effectivePermissionMode = currentId
? activeSessionPermission ?? 'default-sandbox' ? activeSessionPermission ?? 'default-sandbox'
: activeSessionPermission ?? initialPermissionMode() : activeSessionPermission ?? initialPermissionMode()
useEffect(() => {
setPermissionMode(effectivePermissionMode)
}, [effectivePermissionMode, currentId])
/** /**
* 原版 resolveRevertEntryVisible(ConversationTimelineImpl.hasRevision): * 原版 resolveRevertEntryVisible(ConversationTimelineImpl.hasRevision):
* 有 requestId 且不是“历史已全部加载时的首条用户消息”才显示回退入口; * 有 requestId 且不是“历史已全部加载时的首条用户消息”才显示回退入口;
@@ -257,14 +259,17 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
}, [currentId, setDraft]) }, [currentId, setDraft])
const title = sessions.find((s) => s.id === currentId)?.title ?? '新对话' const title = sessions.find((s) => s.id === currentId)?.title ?? '新对话'
const isHome = currentId === null || messages.length === 0 const isHome = currentId === null && !sending && messages.length === 0
const conversationSearch = useConversationSearch(messages) const conversationSearch = useConversationSearch(messages)
const currentSession = sessions.find((session) => session.id === currentId) const currentSession = sessions.find((session) => session.id === currentId)
const workspaceRoot = currentSession?.cwd ?? draftContext.cwd ?? defaultWorkspaceRoot const workspaceRoot = currentSession?.cwd ?? draftContext.cwd ?? defaultWorkspaceRoot
const artifactCount = useMemo(() => collectWorkbenchArtifacts(messages, workspaceRoot).artifacts.length, [messages, workspaceRoot]) const artifactCount = useMemo(() => collectWorkbenchArtifacts(messages, workspaceRoot).artifacts.length, [messages, workspaceRoot])
const effectiveModelId = currentSession?.modelId ?? draftContext.modelId ?? selectedModelId const effectiveModel = resolveSelectedModel(models, currentId ? currentSession?.modelId : draftContext.modelId, selectedModelId)
const effectiveModelId = effectiveModel?.id
const effectiveWorkspaceId = currentSession?.workspaceId ?? draftContext.workspaceId ?? selectedWorkspaceId const effectiveWorkspaceId = currentSession?.workspaceId ?? draftContext.workspaceId ?? selectedWorkspaceId
const effectiveAgentMode = (currentId ? currentSession?.agentMode : draftContext.agentMode) ?? 'craft' const effectiveAgentMode = (currentId ? currentSession?.agentMode : draftContext.agentMode) ?? 'craft'
// Plan changes the effective policy, never the saved preference restored on exit.
const permissionMode = effectiveAgentMode === 'plan' ? 'default-sandbox' : effectivePermissionMode
// Left undefined when the session has no explicit choice; the selector then uses the model's own default. // Left undefined when the session has no explicit choice; the selector then uses the model's own default.
const effectiveThinkingLevel = currentSession?.thinkingLevel ?? draftContext.thinkingLevel const effectiveThinkingLevel = currentSession?.thinkingLevel ?? draftContext.thinkingLevel
const capabilityCatalog = useCapabilityCatalog(workspaceRoot) const capabilityCatalog = useCapabilityCatalog(workspaceRoot)
@@ -420,7 +425,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
}) })
// The selected model label comes from the local registry. // The selected model label comes from the local registry.
const modelLabel = models.find((model) => model.id === effectiveModelId)?.name || config?.model || '未配置模型' const modelLabel = effectiveModel?.name ?? '配置自定义模型'
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
@@ -459,6 +464,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const submit = async ({ text, attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId }: ComposerSendPayload): Promise<void> => { const submit = async ({ text, attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId }: ComposerSendPayload): Promise<void> => {
if (sending) return if (sending) return
if (loading || loadError) throw new Error('会话尚未就绪,请重试或等待加载完成')
if (!effectiveModel) throw new Error('请先配置自定义模型')
if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互') if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互')
followBottom.current = true followBottom.current = true
const submitKey = draftKey const submitKey = draftKey
@@ -470,7 +477,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
useChatStore.setState(state => ({ useChatStore.setState(state => ({
draftContext: { draftContext: {
...state.draftContext, ...state.draftContext,
permissionMode: state.draftContext.permissionMode ?? permissionMode permissionMode: state.draftContext.permissionMode ?? effectivePermissionMode
} }
})) }))
} }
@@ -482,7 +489,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
setDrafts(cleared) setDrafts(cleared)
} }
try { try {
await send(text, { attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId: handoffId, permissionMode }) await send(text, { attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId: handoffId, permissionMode: effectivePermissionMode })
draftMoves.current.delete(submitKey) draftMoves.current.delete(submitKey)
} catch (error) { } catch (error) {
const destination = draftMoves.current.get(submitKey) const destination = draftMoves.current.get(submitKey)
@@ -549,7 +556,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
await send(text, { attachments: edited?.attachments ?? message.attachments, resources: edited?.resources ?? message.resources, quotes: edited?.quotes ?? message.quotes, await send(text, { attachments: edited?.attachments ?? message.attachments, resources: edited?.resources ?? message.resources, quotes: edited?.quotes ?? message.quotes,
skillIds: edited?.skillIds ?? capabilities.skillIds, mcpServerIds: edited?.mcpServerIds ?? capabilities.mcpServerIds, skillIds: edited?.skillIds ?? capabilities.skillIds, mcpServerIds: edited?.mcpServerIds ?? capabilities.mcpServerIds,
command: edited?.command ?? capabilities.command, userContent: edited?.userContent ?? message.userContent, command: edited?.command ?? capabilities.command, userContent: edited?.userContent ?? message.userContent,
permissionMode, fromMessageId: message.id, action: 'edit', rollbackArtifacts }) permissionMode: effectivePermissionMode, fromMessageId: message.id, action: 'edit', rollbackArtifacts })
setEditingMessageId(null) setEditingMessageId(null)
} }
const precedingUserMessage = (message: ChatMessage): ChatMessage | undefined => { const precedingUserMessage = (message: ChatMessage): ChatMessage | undefined => {
@@ -568,7 +575,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
} }
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes,
...messageCapabilityRefs(preceding), ...messageCapabilityRefs(preceding),
userContent: preceding.userContent, permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve() userContent: preceding.userContent, permissionMode: effectivePermissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
} }
const canRegenerateMessage = (message: ChatMessage): boolean => { const canRegenerateMessage = (message: ChatMessage): boolean => {
const preceding = precedingUserMessage(message) const preceding = precedingUserMessage(message)
@@ -609,12 +616,12 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
responding={activeInteraction.status === 'responding'} error={activeInteraction.error} responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }} labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })} onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })}
onSkip={() => void respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} /> onSkip={() => respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true })} />
: activeInteraction?.kind === 'approval' : activeInteraction?.kind === 'approval'
? <PermissionConfirm key={activeInteraction.id} className="permission-floating" ? <PermissionConfirm key={activeInteraction.id} className="permission-floating"
pendingId={activeInteraction.id} toolName={activeInteraction.toolCallId} title={activeInteraction.title} pendingId={activeInteraction.id} details={activeInteraction.details} toolName={activeInteraction.details?.toolName ?? messages.flatMap(message => message.tools ?? []).find(tool => tool.id === activeInteraction.toolCallId)?.name ?? '工具操作'} title={activeInteraction.title}
options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error} options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error}
labels={{ confirm: '允许一次', alwaysAllow: '始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }} labels={{ confirm: '允许一次', alwaysAllow: '本次会话内始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }}
resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })} resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })}
reject={(id) => { reject={(id) => {
const option = activeInteraction.options.find((item) => item.kind.startsWith('reject')) const option = activeInteraction.options.find((item) => item.kind.startsWith('reject'))
@@ -644,7 +651,9 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
onSelectCapability={selectCapability} onSelectCapability={selectCapability}
onRemoveCapability={removeCapability} onRemoveCapability={removeCapability}
capabilityReferences={queuedEditReference} capabilityReferences={queuedEditReference}
disabled={sending || composition.input.disabled} sendDisabled={!effectiveModel}
disabled={sending || loading || !!loadError || composition.input.disabled}
modeSlot={<ModeChip mode={effectiveAgentMode} disabled={sending || loading || !!running} onClear={() => useChatStore.getState().selectAgentMode('craft').catch(addMenuError)} />}
branchLabelSlot={<ComposerBranchLabel sessionId={currentId} />} branchLabelSlot={<ComposerBranchLabel sessionId={currentId} />}
contextUsageSlot={<ContextUsageRing sessionId={currentId} hasMessages={messages.length > 0} running={sending || !!running} />} contextUsageSlot={<ContextUsageRing sessionId={currentId} hasMessages={messages.length > 0} running={sending || !!running} />}
addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`} addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`}
@@ -658,7 +667,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const resourcePath = file.path const resourcePath = file.path
setDrafts(all => { const previous = all[draftKey] ?? { text: '', attachments: [] }; return { ...all, [draftKey]: { ...previous, resources: [...new Set([...(previous.resources ?? []), resourcePath])] } } }) setDrafts(all => { const previous = all[draftKey] ?? { text: '', attachments: [] }; return { ...all, [draftKey]: { ...previous, resources: [...new Set([...(previous.resources ?? []), resourcePath])] } } })
} }} onCloseMenu={closeMenu} />, } }} onCloseMenu={closeMenu} />,
mode: () => <SourceModeSubmenu key={`${effectiveAgentMode}:${modeRevision}`} selectedId={effectiveAgentMode} provider={{ disabled: sending || !!running, onChange: mode => { mode: () => <SourceModeSubmenu key={`${effectiveAgentMode}:${modeRevision}`} selectedId={effectiveAgentMode} provider={{ disabled: sending || loading || !!running, onChange: mode => {
void useChatStore.getState().selectAgentMode(mode.id as AgentMode).catch(reason => { addMenuError(reason); setModeRevision(value => value + 1) }) void useChatStore.getState().selectAgentMode(mode.id as AgentMode).catch(reason => { addMenuError(reason); setModeRevision(value => value + 1) })
} }} /> } }} />
}} }}
@@ -689,19 +698,10 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }} speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }}
onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)} onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)}
permissionPlacement="toolbar" permissionPlacement="toolbar"
permissionSlot={<SourcePermissionControl mode={permissionMode} disabled={sending || !!running || composition.input.disabled} permissionSlot={<SourcePermissionControl mode={permissionMode} disabled={sending || loading || !!loadError || !!running || effectiveAgentMode !== 'craft' || composition.input.disabled}
disabledReason={effectiveAgentMode !== 'craft' ? '计划和仅问答模式下不能修改权限' : undefined}
onChange={async (mode) => { onChange={async (mode) => {
const owner = useChatStore.getState().currentSessionId await useChatStore.getState().selectPermissionMode(mode)
if (owner) {
const session = await window.ycode.session.updateContext(owner, { permissionMode: mode })
useChatStore.setState((state) => ({ sessions: state.sessions.map((item) => item.id === owner ? session : item) }))
if (useChatStore.getState().currentSessionId !== owner) return
setPermissionMode(mode)
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ }
return
}
useChatStore.setState((state) => ({ draftContext: { ...state.draftContext, permissionMode: mode } }))
setPermissionMode(mode)
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ } try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ }
}} }}
onError={(message) => useChatStore.setState({ error: message })} />} onError={(message) => useChatStore.setState({ error: message })} />}
@@ -722,7 +722,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
return ( return (
<section className="chat-pane cr-theme" style={hidden ? { display: 'none' } : undefined} data-view-id="main-content" data-cr-theme={darkMode ? 'dark' : 'light'}> <section className="chat-pane cr-theme" style={hidden ? { display: 'none' } : undefined} data-view-id="main-content" data-cr-theme={darkMode ? 'dark' : 'light'}>
{!isHome && <SourceChatHeader key={currentId} title={title} rightVisible={rightVisible} onToggleRight={onToggleRight} {!isHome && <SourceChatHeader key={currentId} title={title} loadingTitle={loading && !currentSession?.title} rightVisible={rightVisible} onToggleRight={onToggleRight}
search={conversationSearch} onTitleChange={async (nextTitle) => { search={conversationSearch} onTitleChange={async (nextTitle) => {
if (!currentId) return if (!currentId) return
await window.ycode.session.rename(currentId, nextTitle) await window.ycode.session.rename(currentId, nextTitle)
@@ -741,6 +741,9 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
followBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80 followBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
}}> }}>
<div className="chat-thread cr-message-list__content"><div className="cr-document"> <div className="chat-thread cr-message-list__content"><div className="cr-document">
{!loadError && <SessionLoading key={currentId} loading={loading && messages.length === 0} />}
{loadError && <div className="yc-session-load-error" role="alert"><p>会话加载失败:{loadError}</p><button type="button" onClick={() => currentId && void useChatStore.getState().selectSession(currentId)}>重试</button></div>}
{!loading && !loadError && messages.length === 0 && <p className="yc-session-empty">此会话暂无消息</p>}
{messages.map((message) => ( {messages.map((message) => (
<MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode} <MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode}
quotedSourceIds={quotedSourceIds} quotedSourceIds={quotedSourceIds}
@@ -750,7 +753,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
onBuildPlan={async (plan) => { onBuildPlan={async (plan) => {
if (running || sending) throw new Error('请等待当前运行结束') if (running || sending) throw new Error('请等待当前运行结束')
await useChatStore.getState().selectAgentMode('craft') await useChatStore.getState().selectAgentMode('craft')
await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { planId: plan.id, permissionMode }) await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { planId: plan.id, permissionMode: effectivePermissionMode })
}} }}
actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: rewindEntryVisible(message) && !rewinding, actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: rewindEntryVisible(message) && !rewinding,
rollbackPending: rewinding, rollbackPending: rewinding,
+32 -50
View File
@@ -1,4 +1,5 @@
import type { ChatMessage, MessagePart } from '@shared/types' import type { ChatMessage, MessagePart } from '@shared/types'
import { projectQuestionAnswers } from '../workbuddy/conversation/question-projection'
import SourceAgentAvatar from './SourceAgentAvatar' import SourceAgentAvatar from './SourceAgentAvatar'
import SourceArtifactLink from './SourceArtifactLink' import SourceArtifactLink from './SourceArtifactLink'
import { AssistantMessageDetails } from './AssistantMessageDetails' import { AssistantMessageDetails } from './AssistantMessageDetails'
@@ -49,7 +50,7 @@ interface Props {
export default function MessageBubble({ message, assistantAvatar, onQuote, quotedSourceIds, darkMode, actions, editingMessageId, onStartEdit, onCancelEdit, onSubmitEdit, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element { export default function MessageBubble({ message, assistantAvatar, onQuote, quotedSourceIds, darkMode, actions, editingMessageId, onStartEdit, onCancelEdit, onSubmitEdit, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element {
const [actionError, setActionError] = useState<string | null>(null) const [actionError, setActionError] = useState<string | null>(null)
const [liveEventsExpanded, setLiveEventsExpanded] = useState(false) const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
const perform = (action: () => Promise<unknown>): void => { const perform = (action: () => Promise<unknown>): void => {
setActionError(null) setActionError(null)
void action().catch((error) => setActionError(error instanceof Error ? error.message : String(error))) void action().catch((error) => setActionError(error instanceof Error ? error.message : String(error)))
@@ -77,7 +78,8 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
const streaming = message.status === 'streaming' const streaming = message.status === 'streaming'
const parts = getMessageParts(message) const parts = getMessageParts(message)
const tools = (message.tools ?? []).map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool) const questionProjection = projectQuestionAnswers(message)
const tools = questionProjection.tools.map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool)
: tool.metadata?.capability === 'mcp' ? { ...tool, name: 'mcp_call_tool', args: { serverName: tool.metadata.serverName, toolName: tool.metadata.toolName, arguments: tool.args } } : tool) : tool.metadata?.capability === 'mcp' ? { ...tool, name: 'mcp_call_tool', args: { serverName: tool.metadata.serverName, toolName: tool.metadata.toolName, arguments: tool.args } } : tool)
const activeApprovals = new Map(tools.flatMap(tool => { const activeApprovals = new Map(tools.flatMap(tool => {
const request = findToolApproval(message.interactions, tool.id) const request = findToolApproval(message.interactions, tool.id)
@@ -86,14 +88,18 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
const activeQuestionIds = new Set((message.interactions ?? []).flatMap(interaction => const activeQuestionIds = new Set((message.interactions ?? []).flatMap(interaction =>
interaction.kind === 'question' && ['pending', 'responding', 'error'].includes(interaction.status) ? [interaction.toolCallId] : [])) interaction.kind === 'question' && ['pending', 'responding', 'error'].includes(interaction.status) ? [interaction.toolCallId] : []))
const classifyTool = (part: Extract<MessagePart, { type: 'tool' }>): CompletedFoldToolCallClassification | undefined => { const classifyTool = (part: Extract<MessagePart, { type: 'tool' }>): CompletedFoldToolCallClassification | undefined => {
const name = tools.find(tool => tool.id === part.toolId)?.name ?? '' if (questionProjection.positionedAnswers.has(part.toolId) || activeQuestionIds.has(part.toolId)) return { role: 'inline-question' }
const name = tools.find(tool => tool.id === part.toolId)?.name.toLowerCase() ?? ''
if (['show_widget', 'visualize:show_widget', 'visualizer:show_widget'].includes(name)) return { role: 'show-widget' } if (['show_widget', 'visualize:show_widget', 'visualizer:show_widget'].includes(name)) return { role: 'show-widget' }
if (['ask_followup_question', 'ask_user_question', 'askfollowupquestion', 'askuserquestion'].includes(name)) return { role: 'inline-question' } if (['ask_followup_question', 'ask_user_question', 'askfollowupquestion', 'askuserquestion'].includes(name)) return { role: 'inline-question' }
return undefined return undefined
} }
const renderTool = (id: string): React.ReactNode => { const renderTool = (id: string): React.ReactNode => {
const tool = tools.find((item) => item.id === id) const tool = tools.find((item) => item.id === id)
if (!tool) return null if (!tool) {
const answer = questionProjection.positionedAnswers.get(id)
return answer ? <QuestionAnswerTranscript questionAnswer={answer} /> : null
}
if (activeQuestionIds.has(tool.id)) return null if (activeQuestionIds.has(tool.id)) return null
const nativeResult = tool.result ?? tool.output ?? tool.summary const nativeResult = tool.result ?? tool.output ?? tool.summary
const result = nativeResult && typeof nativeResult === 'object' && 'result' in nativeResult ? nativeResult : { result: nativeResult } const result = nativeResult && typeof nativeResult === 'object' && 'result' in nativeResult ? nativeResult : { result: nativeResult }
@@ -130,65 +136,41 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
<CompletedFold parts={parts} foldId={message.id} <CompletedFold parts={parts} foldId={message.id}
turnFinished={message.status === 'done' || message.status === 'cancelled' || message.status === 'error'} turnFinished={message.status === 'done' || message.status === 'cancelled' || message.status === 'error'}
turnCancelled={message.status === 'cancelled'} turnDurationMs={message.turnDurationMs} turnCancelled={message.status === 'cancelled'} turnDurationMs={message.turnDurationMs}
expandFinishedEvents={liveEventsExpanded}
classifyToolCall={classifyTool} classifyToolCall={classifyTool}
renderGroup={(group, children) => { renderGroup={(group, children) => {
if (!group.isRunning) return <Fragment key={group.key}>{children}</Fragment>
const groupedTools = group.units.flatMap(unit => { const groupedTools = group.units.flatMap(unit => {
const part = unit.part as MessagePart const part = unit.part as MessagePart
return part.type === 'tool' ? tools.filter(tool => tool.id === part.toolId) : [] return part.type === 'tool' ? tools.filter(tool => tool.id === part.toolId) : []
}) })
if (groupedTools.some(tool => activeApprovals.has(tool.id))) return <Fragment key={group.key}>{children}</Fragment> if (groupedTools.some(tool => activeApprovals.has(tool.id))) return <Fragment key={group.key}>{children}</Fragment>
const latestProgressIndex = parts.reduce((latest, part, index) => part.type === 'tool' || part.type === 'reasoning' ? index : latest, -1) const latestProgressIndex = group.units.at(-1)!.index
if (!group.units.some(unit => unit.index === latestProgressIndex)) return null
const latestPart = parts[latestProgressIndex] const latestPart = parts[latestProgressIndex]
const latestTool = latestPart?.type === 'tool' ? tools.find(tool => tool.id === latestPart.toolId) : undefined const latestTool = latestPart?.type === 'tool' ? tools.find(tool => tool.id === latestPart.toolId) : undefined
const toolArgs = latestTool?.args && typeof latestTool.args === 'object' ? latestTool.args as Record<string, unknown> : {} const summaryParts = group.units.map(unit => {
const summaryArgs = latestTool && ['list_dir', 'list_files'].includes(latestTool.name) && !toolArgs.target_directory && !toolArgs.root && typeof toolArgs.path === 'string' if (unit.part.type !== 'tool') return { type: unit.part.type }
? { ...toolArgs, target_directory: toolArgs.path } const toolId = unit.part.toolId
: toolArgs const tool = tools.find(item => item.id === toolId)
const pendingConfirmation = !!latestTool && activeApprovals.has(latestTool.id) const args = tool?.args && typeof tool.args === 'object' ? tool.args as Record<string, unknown> : {}
const summaryStatus = !latestTool ? 'success' : pendingConfirmation ? 'pending' const summaryArgs = tool && ['list_dir', 'list_files'].includes(tool.name) && !args.target_directory && !args.root && typeof args.path === 'string'
: latestTool.status === 'ok' ? 'executed' ? { ...args, target_directory: args.path } : args
: ['running', 'executing'].includes(latestTool.status) ? 'stream_executing' return { type: 'tool-call', tool: tool ? { name: tool.name, args: summaryArgs,
: latestTool.status result: tool.result ?? tool.output, pendingConfirmation: activeApprovals.has(tool.id),
const actionSummary = latestTool ? computeSourceFoldSummary([{ type: 'tool-call', tool: { status: tool.status === 'ok' ? 'executed' : tool.status === 'running' ? 'stream_executing' : tool.status } : undefined }
name: latestTool.name, args: summaryArgs, result: latestTool.result ?? latestTool.output, })
pendingConfirmation, status: summaryStatus const actionSummary = computeSourceFoldSummary(summaryParts, { isRunning: group.isRunning, adjacentText: group.adjacentText,
} }], { isRunning: true, t: (key, params) => sourceConversationLabel(key as SourceConversationLabelKey, params) })
t: (key, params) => sourceConversationLabel(key as SourceConversationLabelKey, params) }) : undefined
const actionText = actionSummary?.text || conversationLabel('message.loading.thinking') const actionText = actionSummary?.text || conversationLabel('message.loading.thinking')
const activityIcon = latestTool ? resolveSourceToolIcon(latestTool.name) : undefined const activityIcon = latestTool ? resolveSourceToolIcon(latestTool.name) : undefined
const liveEventParts: Array<{ part: Extract<MessagePart, { type: 'tool' | 'reasoning' }>; index: number }> = []
parts.forEach((part, eventIndex) => {
if (part.type === 'reasoning') {
if (part.text.trim()) liveEventParts.push({ part, index: eventIndex })
return
}
if (part.type !== 'tool') return
const tool = tools.find(item => item.id === part.toolId)
if (!tool || classifyTool(part)?.role) return
if (activeApprovals.has(tool.id) || activeQuestionIds.has(tool.id)) return
liveEventParts.push({ part, index: eventIndex })
})
const activityHeader = <div className="cr-captured-tool cr-streaming-activity" role="status" aria-live="polite" data-activity-tool-id={latestTool?.id}> const activityHeader = <div className="cr-captured-tool cr-streaming-activity" role="status" aria-live="polite" data-activity-tool-id={latestTool?.id}>
<SourceToolHeader icon={activityIcon} showIconWhileExecuting <SourceToolHeader icon={activityIcon} showIconWhileExecuting
isExecuting={actionSummary?.status === 'running'} primaryLoading={actionSummary?.status === 'running'} isExecuting={actionSummary?.status === 'running'} primaryLoading={actionSummary?.status === 'running'}
primaryContent={actionText} /> primaryContent={actionText} />
</div> </div>
return <LiveEventDisclosure id={`live-events-${encodeURIComponent(message.id)}`} return <LiveEventDisclosure id={`events-${encodeURIComponent(message.id)}-${group.startIndex}`}
header={activityHeader} expanded={liveEventsExpanded} onExpandedChange={setLiveEventsExpanded}> header={activityHeader} expanded={expandedGroups[group.key] ?? false}
{liveEventParts.map(({ part, index: eventIndex }) => part.type === 'tool' onExpandedChange={expanded => setExpandedGroups(previous => ({ ...previous, [group.key]: expanded }))}>
? <div className="cr-live-event-list__item" data-event-type="tool" key={`tool:${part.toolId}`}> {children}
{renderTool(part.toolId)}
</div>
: <div className="cr-live-event-list__item" data-event-type="reasoning" key={`reasoning:${part.id}`}>
<ThinkingBlock text={part.text} isFinished={part.finished || !streaming}
isLastContent={eventIndex === parts.length - 1} title={sourceConversationLabel('reasoning.title')}
renderMarkdown={text => <MarkdownContent content={text} darkMode={darkMode} />} />
</div>)}
</LiveEventDisclosure> </LiveEventDisclosure>
}} }}
renderUnit={(candidate, index) => { renderUnit={(candidate, index) => {
@@ -222,11 +204,11 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
) )
}} /> }} />
{message.interactions?.map((interaction) => interaction.kind === 'question' && interaction.answer && {questionProjection.unpositioned.length > 0 && <section className="question-history-unpositioned" aria-label="历史问答(原始位置不可用)">
!message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) {questionProjection.unpositioned.map(item => <QuestionAnswerTranscript key={item.id} questionAnswer={item.answer} />)}
? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)} </section>}
{streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} />} {streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} progress={message.runtimeProgress} reasoningOnly={parts.length > 0 && parts.every(part => part.type === 'reasoning')} />}
{message.status === 'error' && message.error ? ( {message.status === 'error' && message.error ? (
<div className="msg__status msg__status--error">{message.error}</div> <div className="msg__status msg__status--error">{message.error}</div>
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react'
/** Only mounts for an uncached session. The tail fades without delaying readiness. */
export function SessionLoading({ loading }: { loading: boolean }): React.JSX.Element | null {
const [visible, setVisible] = useState(loading)
useEffect(() => {
if (loading) { setVisible(true); return }
const timer = window.setTimeout(() => setVisible(false), 300)
return () => window.clearTimeout(timer)
}, [loading])
if (!loading && !visible) return null
return <div className={`yc-session-skeleton${loading ? '' : ' yc-session-skeleton--leaving'}`} role="status" aria-label="正在加载会话" aria-live="polite">
{[0, 1].map(index => <div className="yc-session-skeleton__group" key={index} aria-hidden="true">
<div className="yc-session-skeleton__user yc-session-shimmer" />
<div className="yc-session-skeleton__identity"><span className="yc-session-skeleton__avatar yc-session-shimmer" /><span className="yc-session-skeleton__name yc-session-shimmer" /></div>
<div className="yc-session-skeleton__line yc-session-shimmer" /><div className="yc-session-skeleton__line yc-session-shimmer" /><div className="yc-session-skeleton__line yc-session-skeleton__line--short yc-session-shimmer" />
</div>)}
</div>
}
+23 -9
View File
@@ -27,8 +27,7 @@ import { AccountMenu } from '../workbuddy/user-menu/AccountMenu'
import { UpdateSidebarCard } from './updates/UpdateSidebarCard' import { UpdateSidebarCard } from './updates/UpdateSidebarCard'
import ArchivedTasksDialog from './ArchivedTasksDialog' import ArchivedTasksDialog from './ArchivedTasksDialog'
import { TaskContextMenu } from './task-menu' import { TaskContextMenu } from './task-menu'
import { TaskConfirmDialog } from './task-confirm-dialog' import { TaskPopconfirm } from './TaskPopconfirm'
import type { TaskConfirmKind } from './task-confirm-dialog'
import { IconTaskArchive, IconTaskMore as TaskMoreIcon, IconTaskPinBadge as TaskPinIcon } from './task-menu-icons' import { IconTaskArchive, IconTaskMore as TaskMoreIcon, IconTaskPinBadge as TaskPinIcon } from './task-menu-icons'
import type { TaskMenuAction, TaskMenuAnchor } from './task-menu' import type { TaskMenuAction, TaskMenuAnchor } from './task-menu'
import { MOCK_ACCOUNT, MOCK_UNREAD_COUNT, NAV_ITEMS } from '../mock' import { MOCK_ACCOUNT, MOCK_UNREAD_COUNT, NAV_ITEMS } from '../mock'
@@ -119,7 +118,8 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
const [workspaceDialog, setWorkspaceDialog] = useState<{ mode: 'create' | 'rename' | 'remove'; space?: WorkspaceRecord } | null>(null) const [workspaceDialog, setWorkspaceDialog] = useState<{ mode: 'create' | 'rename' | 'remove'; space?: WorkspaceRecord } | null>(null)
const [spaceMenu, setSpaceMenu] = useState<{ space: WorkspaceRecord; top: number; left: number; anchorTop: number } | null>(null) const [spaceMenu, setSpaceMenu] = useState<{ space: WorkspaceRecord; top: number; left: number; anchorTop: number } | null>(null)
const [taskMenu, setTaskMenu] = useState<{ session: SessionMeta; anchor: TaskMenuAnchor } | null>(null) const [taskMenu, setTaskMenu] = useState<{ session: SessionMeta; anchor: TaskMenuAnchor } | null>(null)
const [taskConfirm, setTaskConfirm] = useState<{ session: SessionMeta; kind: TaskConfirmKind; running: boolean } | null>(null) const [taskConfirm, setTaskConfirm] = useState<{ session: SessionMeta; kind: 'archive' | 'delete'; running: boolean; anchor: HTMLElement } | null>(null)
const taskRows = useRef(new Map<string, HTMLDivElement>())
const [archivedOpen, setArchivedOpen] = useState(false) const [archivedOpen, setArchivedOpen] = useState(false)
const [renaming, setRenaming] = useState<{ sessionId: string; value: string } | null>(null) const [renaming, setRenaming] = useState<{ sessionId: string; value: string } | null>(null)
const spaceMenuRef = useRef<HTMLDivElement>(null) const spaceMenuRef = useRef<HTMLDivElement>(null)
@@ -269,13 +269,15 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
if (action === 'unpin') { void setPinned(session.id, false).catch((error) => report(error, '取消置顶失败')); return } if (action === 'unpin') { void setPinned(session.id, false).catch((error) => report(error, '取消置顶失败')); return }
if (action === 'openWindow') { void window.ycode.session.openWindow(session.id).catch((error) => report(error, '无法在独立窗口中打开')); return } if (action === 'openWindow') { void window.ycode.session.openWindow(session.id).catch((error) => report(error, '无法在独立窗口中打开')); return }
if (action === 'openFolder') { void window.ycode.session.openFolder(session.id).catch((error) => report(error, '无法打开文件夹')); return } if (action === 'openFolder') { void window.ycode.session.openFolder(session.id).catch((error) => report(error, '无法打开文件夹')); return }
const anchor = taskRows.current.get(session.id)
if (!anchor?.isConnected) return
if (action === 'archive') { if (action === 'archive') {
const running = isSessionRunning(session.id) const running = isSessionRunning(session.id)
if (running) { setHint('任务进行中,无法归档'); return } if (running) { setHint('任务进行中,无法归档'); return }
setTaskConfirm({ session, kind: 'archive', running: false }) setTaskConfirm({ session, kind: 'archive', running: false, anchor })
return return
} }
setTaskConfirm({ session, kind: 'delete', running: isSessionRunning(session.id) }) setTaskConfirm({ session, kind: 'delete', running: isSessionRunning(session.id), anchor })
} }
const renderSession = (session: SessionMeta, child = false): React.JSX.Element => { const renderSession = (session: SessionMeta, child = false): React.JSX.Element => {
@@ -283,7 +285,7 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
const pinned = session.pinnedAt !== undefined const pinned = session.pinnedAt !== undefined
const status = Object.values(runs).some(run => run.sessionId === session.id) ? '运行中' : sendingSessions.includes(session.id) ? '发送中' : relTime(session.updatedAt) const status = Object.values(runs).some(run => run.sessionId === session.id) ? '运行中' : sendingSessions.includes(session.id) ? '发送中' : relTime(session.updatedAt)
return ( return (
<div key={session.id} className={`sidebar__item workspace-source-task conversation-agent-card${child ? ' conversation-agent-card--group-child' : ' conversation-agent-card--standalone'}${pinned ? ' conversation-pinned-item' : ''}${session.id === currentId ? ' selected' : ''}${taskMenu?.session.id === session.id ? ' sidebar__item--menu-open' : ''}`} <div key={session.id} ref={element => { if (element) taskRows.current.set(session.id, element); else taskRows.current.delete(session.id) }} className={`sidebar__item workspace-source-task conversation-agent-card${child ? ' conversation-agent-card--group-child' : ' conversation-agent-card--standalone'}${pinned ? ' conversation-pinned-item' : ''}${session.id === currentId ? ' selected' : ''}${taskMenu?.session.id === session.id ? ' sidebar__item--menu-open' : ''}`}
data-selected={session.id === currentId} data-session-id={session.id} role="button" tabIndex={0} aria-haspopup="menu" data-selected={session.id === currentId} data-session-id={session.id} role="button" tabIndex={0} aria-haspopup="menu"
onClick={() => { if (editing) return; onNavigate?.('chat'); void selectSession(session.id) }} onClick={() => { if (editing) return; onNavigate?.('chat'); void selectSession(session.id) }}
onContextMenu={(event) => { onContextMenu={(event) => {
@@ -520,10 +522,22 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
onSelect={(action) => handleTaskMenu(taskMenu.session, action)} onSelect={(action) => handleTaskMenu(taskMenu.session, action)}
onClose={() => setTaskMenu(null)} /> : null} onClose={() => setTaskMenu(null)} /> : null}
{taskConfirm ? <TaskConfirmDialog {taskConfirm ? <TaskPopconfirm
anchor={taskConfirm.anchor}
kind={taskConfirm.kind} kind={taskConfirm.kind}
running={taskConfirm.running} running={isSessionRunning(taskConfirm.session.id)}
onClose={() => setTaskConfirm(null)} onClose={() => {
const previous = taskConfirm.anchor
setTaskConfirm(null)
// Removed rows cannot receive focus after a successful archive/delete.
const fallback = document.querySelector<HTMLElement>('.sidebar [data-session-id], .sidebar button')
;(previous.isConnected ? previous : fallback)?.focus({ preventScroll: true })
requestAnimationFrame(() => {
if (!previous.isConnected && (document.activeElement === document.body || !document.activeElement?.isConnected)) {
document.querySelector<HTMLElement>('.sidebar [data-session-id], .sidebar button')?.focus({ preventScroll: true })
}
})
}}
onConfirm={async () => { onConfirm={async () => {
if (taskConfirm.kind === 'archive') await setArchived(taskConfirm.session.id, true) if (taskConfirm.kind === 'archive') await setArchived(taskConfirm.session.id, true)
else await removeSession(taskConfirm.session.id) else await removeSession(taskConfirm.session.id)
@@ -26,6 +26,7 @@ export interface SourceChatHeaderPromptList {
} }
export interface SourceChatHeaderProps { export interface SourceChatHeaderProps {
title: string title: string
loadingTitle?: boolean
rightVisible: boolean rightVisible: boolean
onToggleRight(): void onToggleRight(): void
/** Kept for the existing parent contract. The source header has no standalone settings gear. */ /** Kept for the existing parent contract. The source header has no standalone settings gear. */
@@ -52,7 +53,7 @@ function HeaderIconButton({ label, onClick, disabled, children }: {
} }
const iconProps = { width: 16, height: 16, 'aria-hidden': true, className: 'wb-icon' } as const const iconProps = { width: 16, height: 16, 'aria-hidden': true, className: 'wb-icon' } as const
export default function SourceChatHeader({ title, rightVisible, onToggleRight, onTitleChange, search, export default function SourceChatHeader({ title, loadingTitle, rightVisible, onToggleRight, onTitleChange, search,
onShare, shareLabel = labels['contextMenu.shareTask'], promptList, onActionError, actions }: SourceChatHeaderProps): React.JSX.Element { onShare, shareLabel = labels['contextMenu.shareTask'], promptList, onActionError, actions }: SourceChatHeaderProps): React.JSX.Element {
const [editing, setEditing] = useState(false), [draftTitle, setDraftTitle] = useState(title) const [editing, setEditing] = useState(false), [draftTitle, setDraftTitle] = useState(title)
const titleInput = useRef<HTMLInputElement>(null), searchInput = useRef<HTMLInputElement>(null) const titleInput = useRef<HTMLInputElement>(null), searchInput = useRef<HTMLInputElement>(null)
@@ -113,8 +114,8 @@ export default function SourceChatHeader({ title, rightVisible, onToggleRight, o
if (event.key === 'Enter') { event.preventDefault(); finishTitle() } if (event.key === 'Enter') { event.preventDefault(); finishTitle() }
if (event.key === 'Escape') { event.preventDefault(); editActive.current = false; setEditing(false); setDraftTitle(title) } if (event.key === 'Escape') { event.preventDefault(); editActive.current = false; setEditing(false); setDraftTitle(title) }
}} /> : <span className="workbuddy-topbar-title-area"> }} /> : <span className="workbuddy-topbar-title-area">
<span className="workbuddy-topbar-title" title={title}>{title}</span> <span className={loadingTitle ? "workbuddy-topbar-title yc-session-title-placeholder" : "workbuddy-topbar-title"} title={loadingTitle ? undefined : title} aria-label={loadingTitle ? "正在加载任务标题" : undefined}>{loadingTitle ? "\u00a0" : title}</span>
<button type="button" className="workbuddy-topbar-edit-button" aria-label="Edit title" disabled={!onTitleChange} <button type="button" className="workbuddy-topbar-edit-button" aria-label="Edit title" disabled={loadingTitle || !onTitleChange}
onClick={event => { event.stopPropagation(); if (!onTitleChange || saving.current) return; setDraftTitle(title); editActive.current = true; setEditing(true) }}> onClick={event => { event.stopPropagation(); if (!onTitleChange || saving.current) return; setDraftTitle(title); editActive.current = true; setEditing(true) }}>
<SourceHeaderEditIcon className={undefined} style={undefined} aria-hidden="true" /> <SourceHeaderEditIcon className={undefined} style={undefined} aria-hidden="true" />
</button> </button>
@@ -0,0 +1,61 @@
import { useEffect, useId, useRef, useState } from 'react'
import { Popover } from '../ui/Popover'
import './task-popconfirm.css'
export interface TaskPopconfirmProps {
kind: 'archive' | 'delete'
running: boolean
anchor: HTMLElement
onConfirm(): Promise<void>
onClose(): void
}
/** Destination restored from the recorded REA observation, not a claim that YCode has that page. */
export function taskConfirmationCopy(kind: 'archive' | 'delete', running: boolean) {
return kind === 'archive'
? { title: '归档任务', description: '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。', confirm: '确认归档' }
: running
? { title: '删除任务?', description: '该任务正在执行中,删除将立即终止运行,且无法恢复。', confirm: '停止并删除' }
: { title: '删除任务?', description: '删除后,这些任务将从你的所有设备中移除,且无法恢复。', confirm: '确认' }
}
/** Task-row confirmation. Queue editing and full-access confirmation keep their modal shells. */
export function TaskPopconfirm({ kind, running, anchor, onConfirm, onClose }: TaskPopconfirmProps): React.JSX.Element {
const copy = taskConfirmationCopy(kind, running)
const titleId = useId(), descriptionId = useId(), errorId = useId()
const lock = useRef(false), mounted = useRef(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
const close = (): void => { if (!lock.current) onClose() }
const confirm = async (): Promise<void> => {
if (lock.current) return
lock.current = true
setBusy(true); setError(null)
try {
await onConfirm()
if (mounted.current) onClose()
} catch (cause) {
if (mounted.current) setError(cause instanceof Error ? cause.message : '操作失败,请重试')
} finally {
lock.current = false
if (mounted.current) setBusy(false)
}
}
return <Popover anchorElement={anchor} returnFocusElement={anchor} open dismissible={!busy} onOpenChange={next => { if (!next) { if (!anchor.isConnected) onClose(); else close() } }}
placement="right" portalRoot="body" arrow consumeOutsidePointer manageFocus initialFocus={1}
hostClassName="workspace-source task-popconfirm-source" className="task-popconfirm" role="dialog"
ariaLabelledBy={titleId} ariaDescribedBy={`${descriptionId}${error ? ` ${errorId}` : ''}`}>
<div aria-busy={busy} data-task-confirm-kind={kind} data-task-confirm-running={running || undefined}>
<h2 className="task-popconfirm__title" id={titleId}>{copy.title}</h2>
<p className="task-popconfirm__description" id={descriptionId}>{copy.description}</p>
{kind === 'archive' && <p className="task-popconfirm__compatibility">YCode 暂无此设置入口,请从侧栏“已归档”查看和恢复。</p>}
{error && <div className="task-popconfirm__error" id={errorId} role="alert">{error}</div>}
<div className="task-popconfirm__actions">
<button type="button" className="wb-button wb-button--secondary wb-button--small task-popconfirm__cancel" disabled={busy} onClick={close}>取消</button>
<button type="button" className={`wb-button wb-button--primary wb-button--small task-popconfirm__confirm${kind === 'delete' ? ' wb-button--danger' : ''}`}
disabled={busy} onClick={() => void confirm()}>{busy ? '处理中…' : copy.confirm}</button>
</div>
</div>
</Popover>
}
@@ -0,0 +1,16 @@
.chat-thread .cr-document { position:relative; }
.yc-session-skeleton { position:relative; width:100%; opacity:1; transition:opacity 300ms ease; }
.yc-session-skeleton--leaving { position:absolute; inset:0; opacity:0; pointer-events:none; }
.yc-session-skeleton__group { display:flex; flex-direction:column; gap:var(--yc-space-4); margin-bottom:var(--yc-space-9); }
.yc-session-shimmer, .yc-session-title-placeholder { background:var(--cr-bg-tertiary); border-radius:var(--yc-radius-md); overflow:hidden; position:relative; }
.yc-session-shimmer::after, .yc-session-title-placeholder::after { content:""; position:absolute; inset:0; transform:translateX(-100%); background:linear-gradient(90deg,transparent,var(--cr-bg-primary),transparent); animation:yc-session-shimmer 1.5s infinite; }
.yc-session-skeleton__user { width:38%; height:var(--yc-space-9); align-self:flex-end; border-radius:var(--yc-radius-2xl); margin-bottom:var(--yc-space-4); }
.yc-session-skeleton__identity { display:flex; align-items:center; gap:var(--yc-space-3); }
.yc-session-skeleton__avatar { width:var(--yc-space-7); height:var(--yc-space-7); border-radius:50%; }
.yc-session-skeleton__name { width:var(--wb-spacing-12); height:var(--yc-font-size-5); }
.yc-session-skeleton__line { height:var(--yc-font-size-5); width:100%; }
.yc-session-skeleton__line--short { width:65%; }
.yc-session-title-placeholder { display:inline-block; width:calc(var(--yc-space-9) * 3); height:var(--yc-space-6); }
.yc-session-empty, .yc-session-load-error { color:var(--cr-text-secondary); padding:var(--yc-space-7) 0; }
@keyframes yc-session-shimmer { to { transform:translateX(100%); } }
@media(prefers-reduced-motion:reduce) { .yc-session-shimmer::after,.yc-session-title-placeholder::after { animation:none; } .yc-session-skeleton { transition:none; } }
@@ -0,0 +1,30 @@
/* Plan-defined task-row popconfirm; no modal overlay. Original full-text/pixel parity pending REA. */
.task-popconfirm-source .task-popconfirm.wb-popover {
box-sizing: border-box;
width: calc(var(--yc-space-10) * 5);
max-width: calc(100vw - var(--yc-space-5));
padding: var(--yc-space-5);
border-radius: var(--yc-radius-xl);
overflow: visible;
color: var(--wb-color-text-primary);
background: var(--wb-bg-popover);
box-shadow: var(--wb-popover-shadow);
}
.task-popconfirm__title {
margin: 0 0 var(--yc-space-3);
font-size: var(--yc-font-size-5);
line-height: var(--yc-line-height-5-2);
font-weight: 500;
}
.task-popconfirm__description,
.task-popconfirm__compatibility {
margin: 0;
color: var(--wb-color-text-secondary);
font-size: var(--yc-font-size-4);
line-height: var(--yc-line-height-5);
overflow-wrap: anywhere;
}
.task-popconfirm__compatibility { margin-top: var(--yc-space-3); color: var(--wb-color-text-tertiary); }
.task-popconfirm__error { margin-top: var(--yc-space-3); color: var(--wb-color-text-error-default); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); }
.task-popconfirm__actions { display: flex; justify-content: flex-end; gap: var(--yc-space-3); margin-top: var(--yc-space-5); }
.task-popconfirm-source .task-popconfirm__actions .wb-button { min-width: calc(var(--yc-space-10) + var(--yc-space-2)); height: var(--yc-line-height-7); padding: 0 var(--yc-space-3); }
+80 -17
View File
@@ -1,4 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { resolveSelectedModel } from '@shared/model-selection'
import { useSettingsStore } from './settings'
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta, TokenUsage } from '@shared/types' import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta, TokenUsage } from '@shared/types'
import type { ActiveChatRun, RewindOptions, SendPayload } from '@shared/ipc' import type { ActiveChatRun, RewindOptions, SendPayload } from '@shared/ipc'
import type { InteractionResponse } from '@shared/conversation' import type { InteractionResponse } from '@shared/conversation'
@@ -6,7 +8,7 @@ import { applyAssistantEvent } from '@shared/message-parts'
import { lastContextUsage, normalizeContextUsageSnapshot } from '@shared/context-usage' import { lastContextUsage, normalizeContextUsageSnapshot } from '@shared/context-usage'
import type { SessionContext } from '@shared/workspaces' import type { SessionContext } from '@shared/workspaces'
import type { ThinkingLevel } from '@shared/workspaces' import type { ThinkingLevel } from '@shared/workspaces'
import type { AgentMode } from '@shared/conversation' import type { AgentMode, PermissionMode } from '@shared/conversation'
import { expertText, type ExpertActivation } from '@shared/capabilities' import { expertText, type ExpertActivation } from '@shared/capabilities'
import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer' import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer'
import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases' import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases'
@@ -67,14 +69,16 @@ interface ChatState {
running: RunningState | null running: RunningState | null
sending: boolean sending: boolean
loading: boolean loading: boolean
loadError: string | null
error: string | null error: string | null
draftContext: SessionContext draftContext: SessionContext
draftId: number draftId: number
init(): Promise<void> init(): Promise<void>
selectSession(id: string): Promise<void> selectSession(id: string, options?: { reload?: boolean }): Promise<void>
newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise<SessionMeta> newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise<SessionMeta>
startTask(context?: SessionContext): void startTask(context?: SessionContext): void
selectModel(id: string): Promise<void> selectModel(id: string): Promise<void>
selectPermissionMode(mode: PermissionMode): Promise<void>
selectExpert(id: string | null): Promise<void> selectExpert(id: string | null): Promise<void>
selectAgentMode(mode: AgentMode): Promise<void> selectAgentMode(mode: AgentMode): Promise<void>
selectThinkingLevel(level: ThinkingLevel): Promise<void> selectThinkingLevel(level: ThinkingLevel): Promise<void>
@@ -123,6 +127,39 @@ let selectionVersion = 0
let expertSelectionVersion = 0 let expertSelectionVersion = 0
let expertWrites: Promise<unknown> = Promise.resolve() let expertWrites: Promise<unknown> = Promise.resolve()
let requestSettingsWrites: Promise<unknown> = Promise.resolve() let requestSettingsWrites: Promise<unknown> = Promise.resolve()
// Serialization is global, but a failed/pending setting only gates its own owner.
const requestSettingsByOwner = new Map<string, Promise<unknown>>()
const requestSettingErrors = new Map<string, Map<keyof SessionContext, unknown>>()
function trackRequestSettings(owner: string, write: Promise<unknown>, keys: readonly (keyof SessionContext)[]): void {
const tracked = write.then(value => {
const failures = requestSettingErrors.get(owner)
for (const key of keys) failures?.delete(key)
if (failures?.size === 0) requestSettingErrors.delete(owner)
return value
}, error => {
const failures = requestSettingErrors.get(owner) ?? new Map<keyof SessionContext, unknown>()
for (const key of keys) failures.set(key, error)
requestSettingErrors.set(owner, failures)
throw error
})
requestSettingsWrites = tracked
requestSettingsByOwner.set(owner, tracked)
void tracked.catch(() => {})
}
async function awaitRequestSettings(owner: string): Promise<void> {
// A later setting may be queued while an earlier save is pending. Await the
// newest owner tail, not another conversation's tail or a stale snapshot.
for (;;) {
const pending = requestSettingsByOwner.get(owner)
try { await pending } catch (error) {
if (pending !== requestSettingsByOwner.get(owner)) continue
throw error
}
if (pending === requestSettingsByOwner.get(owner)) break
}
const failures = requestSettingErrors.get(owner)
if (failures?.size) throw failures.values().next().value
}
const activatingExperts = new Map<string, Promise<ExpertActivation>>() const activatingExperts = new Map<string, Promise<ExpertActivation>>()
const cancelRequestedRuns = new Set<string>() const cancelRequestedRuns = new Set<string>()
function prepareExpert(id: string): Promise<ExpertActivation> { function prepareExpert(id: string): Promise<ExpertActivation> {
@@ -143,11 +180,11 @@ function sortSessions(sessions: SessionMeta[]): SessionMeta[] {
} }
export const useChatStore = create<ChatState>((set, get) => { export const useChatStore = create<ChatState>((set, get) => {
const saveRequestSettings = async (patch: Pick<SessionContext, 'modelId' | 'thinkingLevel'>): Promise<void> => { const saveRequestSettings = async (patch: Pick<SessionContext, 'modelId' | 'thinkingLevel' | 'permissionMode'>): Promise<void> => {
const owner = get().currentSessionId const owner = get().currentSessionId
if (!owner) { set(state => ({ draftContext: { ...state.draftContext, ...patch } })); return } if (!owner) { set(state => ({ draftContext: { ...state.draftContext, ...patch } })); return }
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(owner, patch)) const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(owner, patch))
requestSettingsWrites = write.catch(() => {}) trackRequestSettings(owner, write, Object.keys(patch) as (keyof SessionContext)[])
await write await write
set(state => ({ sessions: state.sessions.map(session => session.id === owner ? { ...session, ...patch } : session), set(state => ({ sessions: state.sessions.map(session => session.id === owner ? { ...session, ...patch } : session),
...contextUsageState(state.sessionUsage, state.sessionContextWindow, owner, ...contextUsageState(state.sessionUsage, state.sessionContextWindow, owner,
@@ -235,7 +272,7 @@ export const useChatStore = create<ChatState>((set, get) => {
sessions: [], currentSessionId: null, messages: [], sessionMessages: {}, sessionUsage: {}, sessionContextWindow: {}, sessions: [], currentSessionId: null, messages: [], sessionMessages: {}, sessionUsage: {}, sessionContextWindow: {},
runs: {}, runSequences: {}, runs: {}, runSequences: {},
sendingSessions: [], sendingSessions: [],
running: null, sending: false, loading: false, error: null, draftContext: {}, draftId: 0, running: null, sending: false, loading: false, loadError: null, error: null, draftContext: {}, draftId: 0,
async init() { async init() {
try { try {
const sessions = await window.ycode.session.list() const sessions = await window.ycode.session.list()
@@ -244,7 +281,8 @@ export const useChatStore = create<ChatState>((set, get) => {
else get().startTask() else get().startTask()
} catch (error) { set({ loading: false, error: error instanceof Error ? error.message : String(error) }) } } catch (error) { set({ loading: false, error: error instanceof Error ? error.message : String(error) }) }
}, },
async selectSession(id) { async selectSession(id, options = {}) {
if (!options.reload && get().currentSessionId === id && !get().loading && !get().loadError) return
++expertSelectionVersion ++expertSelectionVersion
const version = ++selectionVersion const version = ++selectionVersion
void window.ycode.chat.setSessionVisible(id) void window.ycode.chat.setSessionVisible(id)
@@ -257,7 +295,7 @@ export const useChatStore = create<ChatState>((set, get) => {
const hydration: SessionHydration = { version, events: [], started: [] } const hydration: SessionHydration = { version, events: [], started: [] }
sessionHydrations.set(id, hydration) sessionHydrations.set(id, hydration)
streamCommitter.clearSession(id) streamCommitter.clearSession(id)
set((state) => ({ currentSessionId: id, messages: cached ?? [], loading: true, error: null, set((state) => ({ currentSessionId: id, messages: cached ?? [], loading: true, loadError: null, error: null,
running: currentRun(state.runs, id), sending: state.sendingSessions.includes(id) })) running: currentRun(state.runs, id), sending: state.sendingSessions.includes(id) }))
try { try {
const active = typeof window.ycode.chat.activeRun === 'function' ? await window.ycode.chat.activeRun(id) : null const active = typeof window.ycode.chat.activeRun === 'function' ? await window.ycode.chat.activeRun(id) : null
@@ -283,7 +321,7 @@ export const useChatStore = create<ChatState>((set, get) => {
} }
} catch (error) { } catch (error) {
if (version === selectionVersion && get().currentSessionId === id) { if (version === selectionVersion && get().currentSessionId === id) {
set({ loading: false, error: errorText(error) }) set({ loading: false, loadError: errorText(error), error: errorText(error) })
} }
} finally { } finally {
if (sessionHydrations.get(id) === hydration) sessionHydrations.delete(id) if (sessionHydrations.get(id) === hydration) sessionHydrations.delete(id)
@@ -298,12 +336,18 @@ export const useChatStore = create<ChatState>((set, get) => {
++expertSelectionVersion ++expertSelectionVersion
++selectionVersion ++selectionVersion
void window.ycode.chat.setSessionVisible(null) void window.ycode.chat.setSessionVisible(null)
set((state) => ({ currentSessionId: null, messages: [], running: null, sending: false, loading: false, error: null, set((state) => ({ currentSessionId: null, messages: [], running: null, sending: false, loading: false, loadError: null, error: null,
draftContext: context, draftId: state.draftId + 1 })) draftContext: context, draftId: state.draftId + 1 }))
}, },
async selectModel(id) { async selectModel(id) {
await saveRequestSettings({ modelId: id }) await saveRequestSettings({ modelId: id })
}, },
async selectPermissionMode(permissionMode) {
if (get().loading || get().loadError || get().running || get().sending) throw new Error('当前不能修改权限')
const mode = (get().currentSessionId ? get().sessions.find(item => item.id === get().currentSessionId) : get().draftContext)?.agentMode
if (mode && mode !== 'craft') throw new Error('计划和仅问答模式下不能修改权限')
await saveRequestSettings({ permissionMode })
},
async selectExpert(id) { async selectExpert(id) {
await get().summonExpert(id) await get().summonExpert(id)
}, },
@@ -317,13 +361,14 @@ export const useChatStore = create<ChatState>((set, get) => {
set(state => ({ draftContext: { ...state.draftContext, agentMode, ...(agentMode !== 'craft' ? { expertId: null } : {}) } })) set(state => ({ draftContext: { ...state.draftContext, agentMode, ...(agentMode !== 'craft' ? { expertId: null } : {}) } }))
return return
} }
const write = expertWrites.catch(() => {}).then(async () => { const write = Promise.all([expertWrites.catch(() => {}), requestSettingsWrites.catch(() => {})]).then(async () => {
if (!isCurrent()) throw new Error('任务或模式选择已切换') if (!isCurrent()) throw new Error('任务或模式选择已切换')
if (get().running || get().sending) throw new Error('请先停止当前运行') if (get().running) throw new Error('请先停止当前运行')
const session = await window.ycode.session.updateContext(owner, { agentMode }) const session = await window.ycode.session.updateContext(owner, { agentMode })
set(state => ({ sessions: state.sessions.map(item => item.id === owner ? session : item) })) set(state => ({ sessions: state.sessions.map(item => item.id === owner ? session : item) }))
}) })
expertWrites = write expertWrites = write
trackRequestSettings(owner, write, ['agentMode'])
await write await write
if (isCurrent() && agentMode !== 'craft' && typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner, clear: true } })) if (isCurrent() && agentMode !== 'craft' && typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner, clear: true } }))
}, },
@@ -381,7 +426,7 @@ export const useChatStore = create<ChatState>((set, get) => {
if (Object.keys(patch).length) { if (Object.keys(patch).length) {
const id = created.id const id = created.id
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(id, patch)) const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(id, patch))
requestSettingsWrites = write trackRequestSettings(id, write, Object.keys(patch) as (keyof SessionContext)[])
created = { ...created, ...patch } created = { ...created, ...patch }
} }
} }
@@ -391,7 +436,7 @@ export const useChatStore = create<ChatState>((set, get) => {
sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages }, sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages },
...contextUsageState(state.sessionUsage, state.sessionContextWindow, created.id, lastContextUsage(seedMessages)), ...contextUsageState(state.sessionUsage, state.sessionContextWindow, created.id, lastContextUsage(seedMessages)),
...(version === selectionVersion ? { currentSessionId: created.id, messages: seedMessages, running: null, ...(version === selectionVersion ? { currentSessionId: created.id, messages: seedMessages, running: null,
sending: false, loading: false, error: null } : {}) sending: false, loading: false, loadError: null, error: null } : {})
})) }))
return created return created
}, },
@@ -422,6 +467,8 @@ export const useChatStore = create<ChatState>((set, get) => {
async removeSession(id) { async removeSession(id) {
if (get().currentSessionId === id) void window.ycode.chat.setSessionVisible(null) if (get().currentSessionId === id) void window.ycode.chat.setSessionVisible(null)
await window.ycode.session.remove(id) await window.ycode.session.remove(id)
requestSettingsByOwner.delete(id)
requestSettingErrors.delete(id)
pendingEvents.delete(id) pendingEvents.delete(id)
sessionHydrations.delete(id) sessionHydrations.delete(id)
streamCommitter.clearSession(id) streamCommitter.clearSession(id)
@@ -452,6 +499,17 @@ export const useChatStore = create<ChatState>((set, get) => {
async send(text, options = {}) { async send(text, options = {}) {
const trimmed = (text.trim() ? text : (options.userContent ?? []).flatMap(item => item.type === 'text' ? [item.text] : []).join('')).trim() const trimmed = (text.trim() ? text : (options.userContent ?? []).flatMap(item => item.type === 'text' ? [item.text] : []).join('')).trim()
if (get().loading) throw new Error('会话正在加载,请稍后发送') if (get().loading) throw new Error('会话正在加载,请稍后发送')
if (get().loadError) throw new Error('会话加载失败,请重试后发送')
const settings = useSettingsStore.getState()
const context = get().currentSessionId ? get().sessions.find(item => item.id === get().currentSessionId) : get().draftContext
const model = resolveSelectedModel(settings.models, context?.modelId, settings.selectedModelId)
if (!model) throw new Error('请先配置自定义模型')
const modelWrite = context?.modelId !== model.id ? get().selectModel(model.id) : null
// Observe rejection immediately; the guarded submission below surfaces it.
void modelWrite?.catch(() => {})
// Read the saved choice at submission time; a React effect is not authoritative.
const savedPermission = context?.permissionMode ?? options.permissionMode ?? 'default-sandbox'
options = { ...options, permissionMode: savedPermission }
if (options.userContent) options = { ...options, userContent: trimUserContent(options.userContent) } if (options.userContent) options = { ...options, userContent: trimUserContent(options.userContent) }
if (!trimmed && !options.attachments?.length && !options.quotes?.length && !options.resources?.length && !options.skillIds?.length && !options.mcpServerIds?.length && !options.command) return if (!trimmed && !options.attachments?.length && !options.quotes?.length && !options.resources?.length && !options.skillIds?.length && !options.mcpServerIds?.length && !options.command) return
let sessionId = get().currentSessionId let sessionId = get().currentSessionId
@@ -478,14 +536,18 @@ export const useChatStore = create<ChatState>((set, get) => {
} }
if (sessionId && currentRun(get().runs, sessionId)) { if (sessionId && currentRun(get().runs, sessionId)) {
if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文') if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文')
await window.ycode.chat.queue.enqueue({ ...options, sessionId, text: trimmed }) if (modelWrite) await modelWrite
await awaitRequestSettings(sessionId)
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
return return
} }
if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试') if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试')
const queue = window.ycode.chat.queue ? await window.ycode.chat.queue.get(sessionId) : undefined const queue = window.ycode.chat.queue ? await window.ycode.chat.queue.get(sessionId) : undefined
if (queue && (queue.items.length || queue.editingDraft && (options.command || queue.editingDraft.id !== options.queueEditId))) { if (queue && (queue.items.length || queue.editingDraft && (options.command || queue.editingDraft.id !== options.queueEditId))) {
if (options.action || options.command) throw new Error('请先处理待发送队列') if (options.action || options.command) throw new Error('请先处理待发送队列')
await window.ycode.chat.queue.enqueue({ ...options, sessionId, text: trimmed }) if (modelWrite) await modelWrite
await awaitRequestSettings(sessionId)
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
return return
} }
const owner = sessionId const owner = sessionId
@@ -493,8 +555,9 @@ export const useChatStore = create<ChatState>((set, get) => {
set((state) => ({ sendingSessions: [...state.sendingSessions, owner], set((state) => ({ sendingSessions: [...state.sendingSessions, owner],
...(state.currentSessionId === owner ? { sending: true, error: null } : {}) })) ...(state.currentSessionId === owner ? { sending: true, error: null } : {}) }))
try { try {
await requestSettingsWrites if (modelWrite) await modelWrite
const result = await window.ycode.chat.send({ ...options, sessionId: owner, text: trimmed }) await awaitRequestSettings(owner)
const result = await window.ycode.chat.send({ ...options, permissionMode: get().sessions.find(item => item.id === owner)?.permissionMode ?? savedPermission, sessionId: owner, text: trimmed })
const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant', const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant',
content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName, content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName,
assistantDisplay: result.assistantDisplay, createdAt: Date.now(), assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
+32 -3
View File
@@ -1,4 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { useChatStore } from './chat'
import { resolveSelectedModel } from '@shared/model-selection'
import type { AppInfo, GatewayConfigView } from '@shared/types' import type { AppInfo, GatewayConfigView } from '@shared/types'
import type { ConfigPatchLike } from '@shared/ipc' import type { ConfigPatchLike } from '@shared/ipc'
import type { CustomModelInput, ModelRegistryView } from '@shared/models' import type { CustomModelInput, ModelRegistryView } from '@shared/models'
@@ -27,7 +29,7 @@ interface SettingsState {
chooseWorkspace(): Promise<void> chooseWorkspace(): Promise<void>
} }
export const useSettingsStore = create<SettingsState>((set) => ({ export const useSettingsStore = create<SettingsState>((set, get) => ({
config: null, config: null,
workspace: '', workspace: '',
appInfo: null, appInfo: null,
@@ -44,8 +46,22 @@ export const useSettingsStore = create<SettingsState>((set) => ({
]) ])
set({ config, workspace: workspace.root, appInfo, ...registry, ...spaces, loaded: true }) set({ config, workspace: workspace.root, appInfo, ...registry, ...spaces, loaded: true })
}, },
async saveModel(input, overwrite) { set(await window.ycode.models.save(input, overwrite)) }, async saveModel(input, overwrite) {
async deleteModel(id) { set(await window.ycode.models.remove(id)) }, const owner = useChatStore.getState().currentSessionId
const draftId = useChatStore.getState().draftId
const isNew = !get().models.some(model => model.id === (input.previousId || input.id))
const registry = await window.ycode.models.save(input, overwrite)
set(registry)
reconcileModels(registry, input.previousId && input.previousId !== input.id ? { from: input.previousId, to: input.id } : undefined)
// An edit preserves selection; a successful addition becomes the next request's model.
const current = useChatStore.getState()
if (isNew && current.currentSessionId === owner && (owner !== null || current.draftId === draftId)) await current.selectModel(input.id)
},
async deleteModel(id) {
const registry = await window.ycode.models.remove(id)
set(registry)
reconcileModels(registry)
},
async selectModel(id) { set(await window.ycode.models.select(id)) }, async selectModel(id) { set(await window.ycode.models.select(id)) },
async createWorkspace(name) { set(await window.ycode.workspace.generate(name)) }, async createWorkspace(name) { set(await window.ycode.workspace.generate(name)) },
async attachWorkspace(path) { async attachWorkspace(path) {
@@ -67,3 +83,16 @@ export const useSettingsStore = create<SettingsState>((set) => ({
set({ workspace: result.root }) set({ workspace: result.root })
} }
})) }))
/** Only request settings change; in-flight and historical message snapshots stay untouched. */
function reconcileModels(registry: ModelRegistryView, renamed?: { from: string; to: string }): void {
const valid = new Set(registry.models.map(model => model.id))
const fallback = resolveSelectedModel(registry.models, registry.selectedModelId)?.id
const nextId = (id: string | undefined): string | undefined => renamed && id === renamed.from ? renamed.to : id && !valid.has(id) ? fallback : id
useChatStore.setState(state => ({
draftContext: nextId(state.draftContext.modelId) !== state.draftContext.modelId
? { ...state.draftContext, modelId: nextId(state.draftContext.modelId) } : state.draftContext,
sessions: state.sessions.map(session => nextId(session.modelId) !== session.modelId
? { ...session, modelId: nextId(session.modelId) } : session)
}))
}
+47 -20
View File
@@ -1,23 +1,24 @@
/** /**
* YCode UI Popover / PopoverTooltip(P4)。 * YCode UI Popover / PopoverTooltip(P4)。
* *
* 实现逐行迁移自 `components/source-select/SourceSelectPopover.tsx` 的 REA 适配壳, * 基础实现迁移自 `components/source-select/SourceSelectPopover.tsx` 的 REA 适配壳,
* 只改文件位置与导出名:DOM class、floating middleware 顺序、inline/portal 策略、 * 只改文件位置与导出名:DOM class、floating middleware 顺序、inline/portal 策略、
* focus/Escape/outside pointer、transitionPreset 与 aria 合并逻辑一律未改动。 * 新增显式锚点、箭头及点击吞掉等可选能力;默认保持旧调用行为。
* 迁移保留的既有字面量(数值已存在,token 无法立即表达,本次不发明 token、不改行为): * 迁移保留的既有字面量(数值已存在,token 无法立即表达,本次不发明 token、不改行为):
* viewportPadding/offset 默认 8、退出动画 150(reduced-motion 1)、zIndex 1100, * viewportPadding/offset 默认 8、退出动画 150(reduced-motion 1)、zIndex 1100,
* 以及按浮层测量结果写入的行内尺寸。 * 以及按浮层测量结果写入的行内尺寸。
*/ */
import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react' import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { autoUpdate, flip, FloatingFocusManager, offset, shift, size, useFloating, type Padding, type Placement } from '@floating-ui/react' import { arrow as floatingArrow, autoUpdate, flip, FloatingFocusManager, offset, shift, size, useFloating, type Padding, type Placement } from '@floating-ui/react'
import { sourceFloatingPlatform } from '../components/source-select/SourceFloatingPlatform' import { sourceFloatingPlatform } from '../components/source-select/SourceFloatingPlatform'
export { RecoveredSelectTooltip as PopoverTooltip } from '../components/source-select/RecoveredSelectTooltip' export { RecoveredSelectTooltip as PopoverTooltip } from '../components/source-select/RecoveredSelectTooltip'
export interface PopoverProps { export interface PopoverProps {
trigger: ReactElement<any>; children: ReactNode; open: boolean; onOpenChange(open: boolean): void trigger?: ReactElement<any>; anchorElement?: HTMLElement | null; children: ReactNode; open: boolean; onOpenChange(open: boolean): void
disabled?: boolean; placement?: Placement; portalRoot?: 'inline' | 'body' | HTMLElement | (() => HTMLElement | null) disabled?: boolean; placement?: Placement; portalRoot?: 'inline' | 'body' | HTMLElement | (() => HTMLElement | null)
className?: string; role?: string; ariaLabel?: string; manageFocus?: boolean; initialFocus?: number className?: string; role?: string; ariaLabel?: string; manageFocus?: boolean; initialFocus?: number
dismissible?: boolean; arrow?: boolean; consumeOutsidePointer?: boolean; hostClassName?: string; ariaLabelledBy?: string; ariaDescribedBy?: string; returnFocusElement?: HTMLElement | null
matchReferenceWidth?: boolean; viewportPadding?: Padding; offset?: number; transitionPreset?: 'expand' | 'fade' matchReferenceWidth?: boolean; viewportPadding?: Padding; offset?: number; transitionPreset?: 'expand' | 'fade'
} }
// Host adaptation of the original PopoverLayer: identical middleware order, inline strategy, // Host adaptation of the original PopoverLayer: identical middleware order, inline strategy,
@@ -35,7 +36,7 @@ export function Popover(props: PopoverProps): React.JSX.Element {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [open, transitionPreset]) }, [open, transitionPreset])
const close = useCallback(() => onOpenChange(false), [onOpenChange]) const close = useCallback(() => onOpenChange(false), [onOpenChange])
const mergedTrigger = cloneElement(trigger, { const mergedTrigger = trigger ? cloneElement(trigger, {
ref: (element: HTMLElement | null) => { ref: (element: HTMLElement | null) => {
anchor.current = element anchor.current = element
const ref = trigger.props.ref const ref = trigger.props.ref
@@ -44,41 +45,67 @@ export function Popover(props: PopoverProps): React.JSX.Element {
'aria-label': props.ariaLabel ?? trigger.props['aria-label'], 'aria-label': props.ariaLabel ?? trigger.props['aria-label'],
'aria-haspopup': role, 'aria-expanded': open ? 'true' : 'false', 'aria-controls': open ? id : undefined, 'aria-haspopup': role, 'aria-expanded': open ? 'true' : 'false', 'aria-controls': open ? id : undefined,
onClick: (event: React.MouseEvent) => { trigger.props.onClick?.(event); if (!event.defaultPrevented && !disabled) onOpenChange(!open) } onClick: (event: React.MouseEvent) => { trigger.props.onClick?.(event); if (!event.defaultPrevented && !disabled) onOpenChange(!open) }
}) }) : null
return <>{mergedTrigger}{(open || exiting) && <PopoverLayer {...props} anchor={anchor} close={close} id={id} exiting={exiting} placement={placement} portalRoot={portalRoot} className={className} role={role} manageFocus={manageFocus} initialFocus={initialFocus} matchReferenceWidth={matchReferenceWidth} viewportPadding={viewportPadding} transitionPreset={transitionPreset}>{children}</PopoverLayer>}</> return <>{mergedTrigger}{(open || exiting) && <PopoverLayer {...props} anchor={anchor} close={close} id={id} exiting={exiting} placement={placement} portalRoot={portalRoot} className={className} role={role} manageFocus={manageFocus} initialFocus={initialFocus} matchReferenceWidth={matchReferenceWidth} viewportPadding={viewportPadding} transitionPreset={transitionPreset}>{children}</PopoverLayer>}</>
} }
function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, className, role, manageFocus, initialFocus, matchReferenceWidth, viewportPadding, offset: offsetDistance = 8, transitionPreset, children, ariaLabel }: PopoverProps & { anchor: React.RefObject<HTMLElement | null>; close(): void; id: string; exiting: boolean }): React.JSX.Element { function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, className, role, manageFocus, initialFocus, matchReferenceWidth, viewportPadding, offset: offsetDistance = 8, transitionPreset, children, ariaLabel, ariaLabelledBy, ariaDescribedBy, anchorElement, arrow, consumeOutsidePointer, hostClassName, returnFocusElement, dismissible = true }: PopoverProps & { anchor: React.RefObject<HTMLElement | null>; close(): void; id: string; exiting: boolean }): React.JSX.Element {
const inline = portalRoot === 'inline' const inline = portalRoot === 'inline'
const arrowRef = useRef<HTMLSpanElement>(null)
const reference = anchorElement ?? anchor.current
const padding = typeof viewportPadding === 'number' ? viewportPadding : { top: 8, right: 8, bottom: 8, left: 8, ...viewportPadding } const padding = typeof viewportPadding === 'number' ? viewportPadding : { top: 8, right: 8, bottom: 8, left: 8, ...viewportPadding }
const { refs, floatingStyles, context, placement: actual, isPositioned } = useFloating({ const { refs, floatingStyles, context, placement: actual, isPositioned, middlewareData } = useFloating({
open: true, onOpenChange: next => { if (!next && !exiting) close() }, placement, open: true, onOpenChange: next => { if (!next && !exiting && dismissible) close() }, placement,
middleware: [offset(offsetDistance), flip(), shift({ padding }), ...(matchReferenceWidth ? [size({ padding, apply({ elements, rects, availableHeight }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px`, maxHeight: `${Math.max(0, availableHeight)}px` }) } })] : [])], middleware: [offset(offsetDistance), flip(), shift({ padding }), ...(arrow ? [floatingArrow({ element: arrowRef, padding: 12 })] : []), ...(matchReferenceWidth ? [size({ padding, apply({ elements, rects, availableHeight }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px`, maxHeight: `${Math.max(0, availableHeight)}px` }) } })] : [])],
whileElementsMounted: autoUpdate, strategy: inline ? 'absolute' : 'fixed', transform: false, platform: sourceFloatingPlatform whileElementsMounted: autoUpdate, strategy: inline ? 'absolute' : 'fixed', transform: false, platform: sourceFloatingPlatform
}) })
useLayoutEffect(() => { refs.setReference(anchor.current) }, [refs, anchor]) useLayoutEffect(() => { refs.setReference(anchorElement ?? anchor.current) }, [refs, anchorElement, anchor, reference])
// External anchors may disappear after filtering, archiving or collapsing the sidebar.
useEffect(() => {
if (!anchorElement) return
const check = (): void => { if (!anchorElement.isConnected) close() }
const observer = new MutationObserver(check)
observer.observe(document.body, { childList: true, subtree: true })
check()
return () => observer.disconnect()
}, [anchorElement, close])
useEffect(() => { useEffect(() => {
if (exiting) return if (exiting) return
const outside = (event: PointerEvent): void => { const outside = (event: PointerEvent): void => {
if (event.button !== 0 || !(event.target instanceof Node)) return if (event.button !== 0 || !(event.target instanceof Node)) return
if (!refs.floating.current?.contains(event.target) && !anchor.current?.contains(event.target)) close() if (!refs.floating.current?.contains(event.target) && !reference?.contains(event.target)) {
if (consumeOutsidePointer) {
event.preventDefault(); event.stopImmediatePropagation()
// pointerdown prevention alone does not suppress the subsequent click in all browsers.
// Keep this one-shot listener alive through the closing/unmounting of the layer.
const outsideTarget = event.target
const swallow = (click: MouseEvent): void => {
if (click.target === outsideTarget) { click.preventDefault(); click.stopImmediatePropagation() }
cleanup()
}
const cleanup = (): void => { document.removeEventListener('click', swallow, true); clearTimeout(timer) }
const timer = window.setTimeout(cleanup, 750)
document.addEventListener('click', swallow, true)
}
if (dismissible) close()
}
} }
const escape = (event: KeyboardEvent): void => { const escape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return if (event.key !== 'Escape') return
event.preventDefault(); event.stopPropagation(); close(); anchor.current?.focus({ preventScroll: true }) event.preventDefault(); event.stopPropagation(); if (!dismissible) return; close(); (returnFocusElement?.isConnected ? returnFocusElement : reference)?.focus({ preventScroll: true })
} }
document.addEventListener('pointerdown', outside) document.addEventListener('pointerdown', outside, !!consumeOutsidePointer)
// Capture prevents the model editor's document Escape listener from also closing the editor. // Capture prevents the model editor's document Escape listener from also closing the editor.
document.addEventListener('keydown', escape, true) document.addEventListener('keydown', escape, true)
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) } return () => { document.removeEventListener('pointerdown', outside, !!consumeOutsidePointer); document.removeEventListener('keydown', escape, true) }
}, [anchor, close, exiting, refs.floating]) }, [reference, close, exiting, refs.floating, consumeOutsidePointer, returnFocusElement, dismissible])
useEffect(() => { if (refs.floating.current) refs.floating.current.inert = exiting }, [exiting, refs.floating]) useEffect(() => { if (refs.floating.current) refs.floating.current.inert = exiting }, [exiting, refs.floating])
const [side, alignment] = actual.split('-'), expand = transitionPreset === 'expand' const [side, alignment] = actual.split('-'), expand = transitionPreset === 'expand'
const origin = side === 'bottom' || side === 'top' ? `${alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'} ${side === 'bottom' ? 'top' : 'bottom'}` : `${side === 'left' ? 'right' : 'left'} ${alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'}` const origin = side === 'bottom' || side === 'top' ? `${alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'} ${side === 'bottom' ? 'top' : 'bottom'}` : `${side === 'left' ? 'right' : 'left'} ${alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'}`
const element = <div ref={refs.setFloating} id={id} role={role} aria-label={ariaLabel} aria-hidden={exiting || undefined} tabIndex={manageFocus && !exiting ? -1 : undefined} const element = <div ref={refs.setFloating} id={id} role={role} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} aria-hidden={exiting || undefined} tabIndex={manageFocus && !exiting ? -1 : undefined}
className={['wb-popover', 'wb-popover--placement-' + side, 'cb-font-size-capped', expand ? 'wb-popover--expand-' + (exiting ? 'close' : isPositioned ? 'open' : 'initial') : '', className].filter(Boolean).join(' ')} className={['wb-popover', 'wb-popover--placement-' + side, 'cb-font-size-capped', expand ? 'wb-popover--expand-' + (exiting ? 'close' : isPositioned ? 'open' : 'initial') : '', className].filter(Boolean).join(' ')}
style={{ ...floatingStyles, zIndex: 1100, visibility: isPositioned || exiting ? 'visible' : 'hidden', ...(expand ? { transformOrigin: origin } : {}) }}>{children}</div> style={{ ...floatingStyles, zIndex: 1100, visibility: isPositioned || exiting ? 'visible' : 'hidden', ...(expand ? { transformOrigin: origin } : {}) }}>{arrow && <span ref={arrowRef} aria-hidden="true" className="wb-popover__arrow" style={{ position: 'absolute', pointerEvents: 'none', width: 8, height: 8, background: 'inherit', transform: 'rotate(45deg)', left: middlewareData.arrow?.x, top: middlewareData.arrow?.y, [side === 'left' ? 'right' : side === 'right' ? 'left' : side === 'top' ? 'bottom' : 'top']: -4 }} />}{children}</div>
const managed = manageFocus ? <FloatingFocusManager context={context} modal={false} initialFocus={initialFocus ?? refs.floating}>{element}</FloatingFocusManager> : element const managed = manageFocus ? <FloatingFocusManager disabled={!isPositioned || exiting} context={context} modal={false} returnFocus={returnFocusElement ? { current: returnFocusElement } : true} initialFocus={initialFocus ?? refs.floating}>{element}</FloatingFocusManager> : element
if (inline) return managed if (inline) return managed
const target = typeof portalRoot === 'function' ? portalRoot() : portalRoot instanceof HTMLElement ? portalRoot : document.body const target = typeof portalRoot === 'function' ? portalRoot() : portalRoot instanceof HTMLElement ? portalRoot : document.body
return createPortal(<span className="source-select-host">{managed}</span>, target ?? document.body) return createPortal(<span className={["source-select-host", hostClassName].filter(Boolean).join(" ")}>{managed}</span>, target ?? document.body)
} }
@@ -65,6 +65,7 @@ export interface ComposerControlsProps {
/** 右组最前:上下文用量环(TODO 21)。 */ /** 右组最前:上下文用量环(TODO 21)。 */
contextUsageSlot?: ReactNode contextUsageSlot?: ReactNode
permissionSlot?: ReactNode permissionSlot?: ReactNode
modeSlot?: ReactNode
expertSlot?: ReactNode expertSlot?: ReactNode
floatingSlot?: ReactNode floatingSlot?: ReactNode
queueSlot?: ReactNode queueSlot?: ReactNode
@@ -82,6 +83,7 @@ export interface ComposerControlsProps {
workspaceFooter?: ReactNode workspaceFooter?: ReactNode
permissionPlacement?: 'toolbar' | 'footer' permissionPlacement?: 'toolbar' | 'footer'
disabled?: boolean disabled?: boolean
sendDisabled?: boolean
sending?: boolean sending?: boolean
onStop?(): void | Promise<void> onStop?(): void | Promise<void>
placeholder?: string placeholder?: string
@@ -167,6 +169,7 @@ export function ComposerControls({
branchLabelSlot, branchLabelSlot,
contextUsageSlot, contextUsageSlot,
permissionSlot, permissionSlot,
modeSlot,
expertSlot, expertSlot,
floatingSlot, floatingSlot,
queueSlot, queueSlot,
@@ -184,6 +187,7 @@ export function ComposerControls({
workspaceFooter, workspaceFooter,
permissionPlacement = 'toolbar', permissionPlacement = 'toolbar',
disabled = false, disabled = false,
sendDisabled = false,
sending = false, sending = false,
onStop, onStop,
placeholder = '发送消息…', placeholder = '发送消息…',
@@ -423,7 +427,7 @@ export function ComposerControls({
const submit = async (): Promise<void> => { const submit = async (): Promise<void> => {
const editorContent = richEditorRef.current?.content() ?? userContent const editorContent = richEditorRef.current?.content() ?? userContent
const plainText = draftPlainText({ text: value, userContent: editorContent }) const plainText = draftPlainText({ text: value, userContent: editorContent })
if (sendInFlightRef.current || disabled || sending || busyCountRef.current > 0 || pendingAttachments.length || if (sendInFlightRef.current || disabled || sendDisabled || sending || busyCountRef.current > 0 || pendingAttachments.length ||
(!plainText.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0 && (!plainText.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0 &&
!skills.length && !selectedCapabilities.length && !hasDraftUserContent({ userContent: editorContent }))) return !skills.length && !selectedCapabilities.length && !hasDraftUserContent({ userContent: editorContent }))) return
const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES) const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES)
@@ -574,8 +578,9 @@ export function ComposerControls({
if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor) if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor)
} }
}} /> }} />
{permissionSlot}
{branchLabelSlot} {branchLabelSlot}
{permissionSlot}
{modeSlot}
{expertSlot} {expertSlot}
</div> </div>
@@ -594,7 +599,7 @@ export function ComposerControls({
<span className="cr-send-button__icon"><IconStop /></span> <span className="cr-send-button__icon"><IconStop /></span>
</button> </button>
) : ( ) : (
<button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sending || busy || pendingAttachments.length > 0 || !hasSendableContent}> <button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sendDisabled || sending || busy || pendingAttachments.length > 0 || !hasSendableContent}>
<span className="cr-send-button__icon"><RuntimeSendIcon /></span> <span className="cr-send-button__icon"><RuntimeSendIcon /></span>
</button> </button>
)} )}
@@ -0,0 +1,14 @@
import type { AgentMode } from '@shared/conversation'
import './mode-chip.css'
/** Host chip. Icon geometry is local; pending same-version REA SVG/pixel acceptance. */
export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise<void> }): React.JSX.Element | null {
if (mode === 'craft') return null
const label = mode === 'plan' ? '计划' : '仅问答'
return <span className="yc-mode-chip" role="group" tabIndex={0} aria-label={`${label}模式`}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
{mode === 'plan' ? <><rect x="3" y="3" width="10" height="11" rx="1.5" /><rect x="5.5" y="1.5" width="5" height="3" rx="1" /><path d="M5.5 7h5M5.5 10h5" /></> : <path d="M13.5 7.5a5.5 5.5 0 0 1-5.5 5.5H3l-1.5 1v-6.5A5.5 5.5 0 0 1 7 2h1a5.5 5.5 0 0 1 5.5 5.5Z" />}
</svg><span>{label}</span>
{!disabled && <button type="button" className="yc-mode-chip__clear" aria-label={`退出${label}模式`} title="切换到工作模式" onClick={() => void onClear()}>×</button>}
</span>
}
@@ -0,0 +1,6 @@
.yc-mode-chip { position:relative; display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-space-3); background:var(--cr-bg-tertiary); color:var(--cr-text-primary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; }
.yc-mode-chip > svg { flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); }
.yc-mode-chip:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); outline-offset:var(--yc-space-1); }
.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; width:var(--yc-space-5); height:var(--yc-space-5); padding:0; border:0; border-radius:50%; background:var(--cr-bg-tertiary); color:inherit; font-size:var(--yc-space-5); cursor:pointer; opacity:0; position:absolute; right:calc(-1 * var(--wb-spacing-2-5)); top:calc(-1.5 * var(--yc-space-1)); }
.yc-mode-chip:hover .yc-mode-chip__clear, .yc-mode-chip:focus-within .yc-mode-chip__clear { opacity:1; }
.yc-mode-chip__clear:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); }
@@ -19,6 +19,7 @@ const CheckGlyph = SelectCheckGlyph as ComponentType<SVGProps<SVGSVGElement>>
interface Props { interface Props {
mode: PermissionMode mode: PermissionMode
disabled?: boolean disabled?: boolean
disabledReason?: string
labels?: Partial<Record<PermissionMode, string>> labels?: Partial<Record<PermissionMode, string>>
confirmFullAccessOnChange?: boolean confirmFullAccessOnChange?: boolean
onChange(mode: PermissionMode): Promise<void> onChange(mode: PermissionMode): Promise<void>
@@ -29,7 +30,7 @@ interface Props {
* The source panel has one full-access switch, not a two-option permission list. * The source panel has one full-access switch, not a two-option permission list.
* Its checkbox acknowledges risk; it does not change the global confirmation preference. * Its checkbox acknowledges risk; it does not change the global confirmation preference.
*/ */
export default function SourcePermissionControl({ mode, disabled, labels, confirmFullAccessOnChange = true, onChange, onError }: Props): React.JSX.Element { export default function SourcePermissionControl({ mode, disabled, disabledReason, labels, confirmFullAccessOnChange = true, onChange, onError }: Props): React.JSX.Element {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [acknowledged, setAcknowledged] = useState(false) const [acknowledged, setAcknowledged] = useState(false)
@@ -39,6 +40,9 @@ export default function SourcePermissionControl({ mode, disabled, labels, confir
const trigger = useRef<HTMLButtonElement>(null) const trigger = useRef<HTMLButtonElement>(null)
const dialog = useRef<HTMLDivElement>(null) const dialog = useRef<HTMLDivElement>(null)
const id = useId() const id = useId()
useEffect(() => {
if (disabled) { setOpen(false); setConfirming(false); setAcknowledged(false) }
}, [disabled])
const full = mode === 'full-access' const full = mode === 'full-access'
const label = labels?.[mode] ?? t(full ? 'chatInput.permission.fullAccess' : 'chatInput.permission.label') const label = labels?.[mode] ?? t(full ? 'chatInput.permission.fullAccess' : 'chatInput.permission.label')
useEffect(() => { useEffect(() => {
@@ -79,9 +83,9 @@ export default function SourcePermissionControl({ mode, disabled, labels, confir
else { setAcknowledged(false); setConfirming(true) } else { setAcknowledged(false); setConfirming(true) }
} }
return <div className="wb-composer__menu-anchor yc-source-permission-trigger"> return <div className="wb-composer__menu-anchor yc-source-permission-trigger">
<SourceSelectPopover open={open} onOpenChange={next => { if (!confirming) setOpen(next) }} placement="top-start" portalRoot="body" offset={4} <SourceSelectPopover open={open && !disabled} onOpenChange={next => { if (!disabled && !confirming) setOpen(next) }} placement="top-start" portalRoot="body" offset={4}
role="dialog" className="cr-permission-setting-popover" disabled={disabled || pending} role="dialog" className="cr-permission-setting-popover" disabled={disabled || pending}
trigger={<button ref={trigger} type="button" disabled={disabled || pending} aria-label={label} title={label} trigger={<button ref={trigger} type="button" disabled={disabled || pending} aria-label={label} title={disabledReason ?? label}
className={['wb-composer__button', 'cr-input-footer-item', 'cr-permission-setting', full && 'cr-permission-setting--danger', open && 'cr-input-footer-item--open'].filter(Boolean).join(' ')}> className={['wb-composer__button', 'cr-input-footer-item', 'cr-permission-setting', full && 'cr-permission-setting--danger', open && 'cr-input-footer-item--open'].filter(Boolean).join(' ')}>
<span className="cr-input-footer-item__icon" aria-hidden="true">{full ? <ShieldDangerIcon /> : <ShieldSecuredIcon />}</span> <span className="cr-input-footer-item__icon" aria-hidden="true">{full ? <ShieldDangerIcon /> : <ShieldSecuredIcon />}</span>
<span className="cr-input-footer-item__label" data-text={label}><span>{label}</span></span> <span className="cr-input-footer-item__label" data-text={label}><span>{label}</span></span>
@@ -114,7 +118,7 @@ export default function SourcePermissionControl({ mode, disabled, labels, confir
</div> </div>
</div> </div>
</SourceSelectPopover> </SourceSelectPopover>
{confirming && createPortal(<div className="workspace-source source-select-host yc-source-model-selector yc-source-permission cr-theme" data-cr-theme={theme} data-theme={theme}> {confirming && !disabled && createPortal(<div className="workspace-source source-select-host yc-source-model-selector yc-source-permission cr-theme" data-cr-theme={theme} data-theme={theme}>
<div className="wb-modal__overlay wb-modal__overlay--center wb-modal-anim-open" onMouseDown={event => { <div className="wb-modal__overlay wb-modal__overlay--center wb-modal-anim-open" onMouseDown={event => {
if (event.target === event.currentTarget && !pending) { setConfirming(false); setAcknowledged(false) } if (event.target === event.currentTarget && !pending) { setConfirming(false); setAcknowledged(false) }
}}> }}>
@@ -5,7 +5,6 @@ import {
classifyCompletedFoldPart, classifyCompletedFoldPart,
type CompletedFoldOptions, type CompletedFoldOptions,
type CompletedFoldPart, type CompletedFoldPart,
type CompletedFoldRenderUnit,
type CompletedFoldSegment, type CompletedFoldSegment,
type CompletedFoldSummaryGroup, type CompletedFoldSummaryGroup,
type CompletedFoldUnit type CompletedFoldUnit
@@ -159,6 +158,7 @@ export function CompletedFold({
renderUnit: render, renderUnit: render,
renderGroup renderGroup
}: CompletedFoldProps) { }: CompletedFoldProps) {
const [completedExpanded, setCompletedExpanded] = useState(expandFinishedEvents)
const reactId = useId() const reactId = useId()
const foldKey = encodeURIComponent(foldId ?? reactId) const foldKey = encodeURIComponent(foldId ?? reactId)
@@ -180,6 +180,19 @@ export function CompletedFold({
})}</Fragment> })}</Fragment>
})}</> })}</>
// The compact model groups hidden process around visible anchors. Once opened,
// show the original recorded sequence, rather than moving trailing work before A.
if (completedExpanded && model.segments.some(segment => segment.kind === 'completed')) {
return <CollapseSection id={`completed-${foldKey}`} variant="completed" title={model.title}
controlledExpanded onExpandedChange={setCompletedExpanded}>
{parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
return <Fragment key={`${part.type}:${index}`}>{render(part, index, { kind: 'part', classification })}</Fragment>
})}
</CollapseSection>
}
const renderSegments = (segments: readonly CompletedFoldSegment[]) => segments.map((segment, segmentIndex) => { const renderSegments = (segments: readonly CompletedFoldSegment[]) => segments.map((segment, segmentIndex) => {
if (segment.kind === 'unit') return renderPresentationUnit(segment.unit, render, renderGroup) if (segment.kind === 'unit') return renderPresentationUnit(segment.unit, render, renderGroup)
if (segment.kind === 'completed') { if (segment.kind === 'completed') {
@@ -189,7 +202,8 @@ export function CompletedFold({
id={`completed-${foldKey}`} id={`completed-${foldKey}`}
variant="completed" variant="completed"
title={model.title} title={model.title}
defaultExpanded={expandFinishedEvents && segment.units.some(renderUnitContainsEvent)} controlledExpanded={completedExpanded}
onExpandedChange={setCompletedExpanded}
> >
{segment.units.map(unit => renderPresentationUnit(unit, render, renderGroup))} {segment.units.map(unit => renderPresentationUnit(unit, render, renderGroup))}
</CollapseSection> </CollapseSection>
@@ -210,10 +224,3 @@ export function CompletedFold({
return <>{renderSegments(model.segments)}</> return <>{renderSegments(model.segments)}</>
} }
function renderUnitContainsEvent(unit: CompletedFoldUnit | CompletedFoldRenderUnit): boolean {
if ('part' in unit) return unit.part.type === 'tool' || unit.part.type === 'reasoning'
return unit.kind === 'part'
? unit.unit.part.type === 'tool' || unit.unit.part.type === 'reasoning'
: unit.group.units.some(member => member.part.type === 'tool' || member.part.type === 'reasoning')
}
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState, useRef } from 'react'
import type { ApprovalDetails } from '@shared/conversation'
import './permission-intercept.css'
import { ApprovalChevronIcon } from './ApprovalSourceIcon' import { ApprovalChevronIcon } from './ApprovalSourceIcon'
import type { PermissionLabels, PermissionOption } from './types' import type { PermissionLabels, PermissionOption } from './types'
@@ -40,6 +42,7 @@ export interface PermissionConfirmProps {
pendingId: string pendingId: string
toolName: string toolName: string
toolKind?: string toolKind?: string
details?: ApprovalDetails
approvalScope?: 'once' | 'session' approvalScope?: 'once' | 'session'
options: PermissionOption[] options: PermissionOption[]
resolve: (pendingId: string, result: { optionId: string }) => void | Promise<void> resolve: (pendingId: string, result: { optionId: string }) => void | Promise<void>
@@ -57,6 +60,7 @@ export function PermissionConfirm({
pendingId, pendingId,
toolName, toolName,
toolKind, toolKind,
details,
approvalScope, approvalScope,
options, options,
resolve, resolve,
@@ -74,11 +78,25 @@ export function PermissionConfirm({
const [localStatus, setLocalStatus] = useState<PermissionInteractionStatus>('pending') const [localStatus, setLocalStatus] = useState<PermissionInteractionStatus>('pending')
const [localError, setLocalError] = useState<string>() const [localError, setLocalError] = useState<string>()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const busyRef = useRef(false)
const mounted = useRef(true)
const externalStatusRef = useRef(externalStatus)
externalStatusRef.current = externalStatus
const currentId = useRef(pendingId)
currentId.current = pendingId
const firstRef = useRef<HTMLButtonElement>(null)
const rejectRef = useRef<HTMLButtonElement>(null)
const [showRule, setShowRule] = useState(false)
useEffect(() => { useEffect(() => {
mounted.current = true
setLocalStatus('pending') setLocalStatus('pending')
setLocalError(undefined) setLocalError(undefined)
setBusy(false) setBusy(false)
busyRef.current = false
setShowRule(false)
if (details?.defaultToNo) rejectRef.current?.focus(); else firstRef.current?.focus()
return () => { mounted.current = false }
}, [pendingId]) }, [pendingId])
const status = externalStatus !== 'pending' ? externalStatus : localStatus const status = externalStatus !== 'pending' ? externalStatus : localStatus
@@ -88,36 +106,42 @@ export function PermissionConfirm({
const isResponding = busy || status === 'responding' const isResponding = busy || status === 'responding'
const choose = async (option: PermissionOption | undefined) => { const choose = async (option: PermissionOption | undefined) => {
if (!option || busy || !isOpen) return if (!option || busyRef.current || !isOpen) return
busyRef.current = true
setBusy(true) setBusy(true)
setLocalError(undefined) setLocalError(undefined)
setLocalStatus('responding') setLocalStatus('responding')
try { try {
await resolve(pendingId, { optionId: option.optionId }) await resolve(pendingId, { optionId: option.optionId })
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
setLocalStatus('answered') setLocalStatus('answered')
onDecisionComplete?.('answered') onDecisionComplete?.('answered')
} catch (caught) { } catch (caught) {
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
setLocalError(caught instanceof Error ? caught.message : String(caught)) setLocalError(caught instanceof Error ? caught.message : String(caught))
setLocalStatus('error') setLocalStatus('error')
} finally { } finally {
setBusy(false) if (mounted.current && currentId.current === pendingId) { busyRef.current = false; setBusy(false) }
} }
} }
const decline = async () => { const decline = async () => {
if (busy || !isOpen) return if (busyRef.current || !isOpen) return
busyRef.current = true
setBusy(true) setBusy(true)
setLocalError(undefined) setLocalError(undefined)
setLocalStatus('responding') setLocalStatus('responding')
try { try {
await reject(pendingId) await reject(pendingId)
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
setLocalStatus('rejected') setLocalStatus('rejected')
onDecisionComplete?.('rejected') onDecisionComplete?.('rejected')
} catch (caught) { } catch (caught) {
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
setLocalError(caught instanceof Error ? caught.message : String(caught)) setLocalError(caught instanceof Error ? caught.message : String(caught))
setLocalStatus('error') setLocalStatus('error')
} finally { } finally {
setBusy(false) if (mounted.current && currentId.current === pendingId) { busyRef.current = false; setBusy(false) }
} }
} }
@@ -129,43 +153,60 @@ export function PermissionConfirm({
return ( return (
<section <section
className={['conversation-high-credit-approval', `conversation-high-credit-approval--${status}`, className].filter(Boolean).join(' ')} className={['conversation-intercept-card', `conversation-intercept-card--${status}`, className].filter(Boolean).join(' ')}
role="dialog" role="dialog"
aria-label={heading} aria-label={heading}
aria-busy={isResponding} aria-busy={isResponding}
data-pending-id={pendingId} data-pending-id={pendingId}
data-request-kind={details?.requestKind ?? 'permission'}
onKeyDown={event => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return
if (event.key === 'Escape' && isOpen && !isResponding) { event.preventDefault(); event.stopPropagation(); void decline(); return }
const index = Number(event.key) - 1
const action = actionOptions[index]
if (!details?.defaultToNo && action && !event.altKey && !event.ctrlKey && !event.metaKey && !isResponding) {
event.preventDefault()
if (action.kind === 'reject') void decline(); else void choose(action.option)
}
}}
> >
<header className="conversation-high-credit-approval__header"> <header className="conversation-intercept-card__header">
<div className="conversation-high-credit-approval__title-row"><span className="conversation-high-credit-approval__title">{heading}</span></div> <div className="conversation-intercept-card__title-row"><span className="conversation-intercept-card__title">{heading}</span>
{description && <p className="conversation-high-credit-approval__description">{description}</p>} <button type="button" className="conversation-intercept-card__rule-action" aria-expanded={showRule} onClick={() => setShowRule(value => !value)}>查看拦截规则</button>
</div>
{showRule && <div className="conversation-intercept-card__rule" role="status">{details?.rule ? [details.rule.id, details.rule.description].filter(Boolean).join(':') : '当前请求未提供命中规则详情,无法查看。'}</div>}
{(details?.command || details?.target) && <pre className="conversation-intercept-card__description">{details.command || details.target}</pre>}
{details?.suggestedRules?.length ? <pre className="conversation-intercept-card__description">本次会话授权范围:{details.suggestedRules.join('、')}</pre> : details?.sessionUnavailableReason ? <p className="conversation-intercept-card__description">{details.sessionUnavailableReason}</p> : null}
{description && <p className="conversation-intercept-card__description">{description}</p>}
</header> </header>
{status === 'pending' || status === 'responding' || status === 'error' ? ( {status === 'pending' || status === 'responding' || status === 'error' ? (
<> <>
{status === 'error' && <div className="conversation-high-credit-approval__error" role="alert">{labels.failed}{error ? `: ${error}` : ''}</div>} {status === 'error' && <div className="conversation-intercept-card__error" role="alert">{labels.failed}{error ? `: ${error}` : ''}</div>}
<div className="conversation-high-credit-approval__content"> <div className="conversation-intercept-card__content">
{actionOptions.map((action, index) => ( {actionOptions.map((action, index) => (
<button <button
key={`${action.kind}-${action.option?.optionId || 'reject'}`} key={`${action.kind}-${action.option?.optionId || 'reject'}`}
type="button" type="button"
ref={action.kind === 'reject' ? rejectRef : index === 0 ? firstRef : undefined}
className={[ className={[
'conversation-high-credit-approval__option', 'conversation-intercept-card__option',
action.kind === 'reject' && 'conversation-high-credit-approval__option--reject' action.kind === 'reject' && 'conversation-intercept-card__option--reject'
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
disabled={isResponding || (action.kind !== 'reject' && !action.option)} disabled={isResponding || (action.kind !== 'reject' && !action.option)}
aria-label={action.label} aria-label={action.label}
data-option-id={action.option?.optionId} data-option-id={action.option?.optionId}
onClick={() => action.kind === 'reject' ? void decline() : void choose(action.option)} onClick={() => action.kind === 'reject' ? void decline() : void choose(action.option)}
> >
<span className="conversation-high-credit-approval__option-num" aria-hidden="true">{index + 1}</span> <span className="conversation-intercept-card__option-num" aria-hidden="true">{index + 1}</span>
<span className="conversation-high-credit-approval__option-text">{action.label}</span> <span className="conversation-intercept-card__option-text">{action.label}</span>
{action.option && <ApprovalChevronIcon className="conversation-high-credit-approval__option-arrow" />} {action.option && <ApprovalChevronIcon className="conversation-intercept-card__option-arrow" />}
</button> </button>
))} ))}
</div> </div>
{isResponding && <div className="conversation-high-credit-approval__working" role="status">{labels.working}</div>} {isResponding && <div className="conversation-intercept-card__working" role="status">{labels.working}</div>}
</> </>
) : ( ) : (
<div className={`conversation-high-credit-approval__decision conversation-high-credit-approval__decision--${status}`} role="status"> <div className={`conversation-intercept-card__decision conversation-intercept-card__decision--${status}`} role="status">
{status === 'answered' ? labels.approved {status === 'answered' ? labels.approved
: status === 'rejected' ? labels.rejected : status === 'rejected' ? labels.rejected
: status === 'cancelled' ? labels.cancelled : status === 'cancelled' ? labels.cancelled
@@ -53,8 +53,8 @@ export interface QuestionFloatingHandle {
export interface QuestionFloatingProps { export interface QuestionFloatingProps {
config: QuestionFloatingConfig config: QuestionFloatingConfig
onSelect?: (answerText: string, answer: QuestionAnswerPayload) => void | Promise<void> onSelect?: (answerText: string, answer: QuestionAnswerPayload) => void | Promise<void>
onDismiss?: () => void onDismiss?: () => void | Promise<void>
onSkip?: () => void onSkip?: () => void | Promise<void>
className?: string className?: string
confirmVariant?: boolean confirmVariant?: boolean
titleHintIcon?: ReactNode titleHintIcon?: ReactNode
@@ -68,11 +68,11 @@ type CustomAnswerMap = Record<string, string>
type CustomToggleMap = Record<string, boolean> type CustomToggleMap = Record<string, boolean>
export function getQuestionOptionText(option: QuestionOption): string { export function getQuestionOptionText(option: QuestionOption): string {
return typeof option === 'string' ? option : option.question || option.option || '' return typeof option === 'string' ? option : option.question || option.option || option.label || ''
} }
export function getQuestionOptionValue(option: QuestionOption): string { export function getQuestionOptionValue(option: QuestionOption): string {
return typeof option === 'string' ? option : option.id || option.question || option.option || '' return typeof option === 'string' ? option : option.id || option.question || option.option || option.label || ''
} }
export function getDefaultQuestionSelections(questions: ConversationQuestion[]): AnswerMap { export function getDefaultQuestionSelections(questions: ConversationQuestion[]): AnswerMap {
@@ -126,7 +126,7 @@ function isValidQuestion(question: ConversationQuestion): boolean {
function isValidOption(option: QuestionOption): boolean { function isValidOption(option: QuestionOption): boolean {
if (typeof option === 'string') return true if (typeof option === 'string') return true
if (!option || typeof option !== 'object') return false if (!option || typeof option !== 'object') return false
return typeof option.question === 'string' || typeof option.option === 'string' return typeof option.question === 'string' || typeof option.option === 'string' || typeof option.label === 'string'
} }
function answerText(payload: QuestionAnswerPayload, labels: QuestionLabels): string { function answerText(payload: QuestionAnswerPayload, labels: QuestionLabels): string {
@@ -153,16 +153,25 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
) { ) {
const labels = { ...defaultQuestionLabels, ...labelOverrides } const labels = { ...defaultQuestionLabels, ...labelOverrides }
const questions = useMemo(() => (config.questions || []).filter(isValidQuestion), [config.questions]) const questions = useMemo(() => (config.questions || []).filter(isValidQuestion), [config.questions])
const questionKey = questions.map(question => question.id).join('|') const questionKey = `${config.requestId ?? config.toolCallId ?? ''}:${questions.map(question => question.id).join('|')}`
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
const [selected, setSelected] = useState<AnswerMap>(() => getDefaultQuestionSelections(config.questions || [])) const [selected, setSelected] = useState<AnswerMap>(() => getDefaultQuestionSelections(questions))
const [custom, setCustom] = useState<CustomAnswerMap>({}) const [custom, setCustom] = useState<CustomAnswerMap>({})
const [includeCustom, setIncludeCustom] = useState<CustomToggleMap>({}) const [includeCustom, setIncludeCustom] = useState<CustomToggleMap>({})
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const submitting = useRef(false)
const generation = useRef(0)
const advanceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const rootRef = useRef<HTMLElement>(null)
const [customFocused, setCustomFocused] = useState(false) const [customFocused, setCustomFocused] = useState(false)
const customInputRef = useRef<HTMLInputElement>(null) const customInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
generation.current += 1
if (advanceTimer.current) clearTimeout(advanceTimer.current)
submitting.current = false
setLocalError(null)
setIndex(0) setIndex(0)
setCustom({}) setCustom({})
setIncludeCustom({}) setIncludeCustom({})
@@ -171,6 +180,26 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
setSelected(getDefaultQuestionSelections(questions)) setSelected(getDefaultQuestionSelections(questions))
}, [questionKey]) }, [questionKey])
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current) }, [])
useEffect(() => { if (error) { submitting.current = false; setSubmitted(false) } }, [error])
useEffect(() => {
rootRef.current?.querySelector<HTMLElement>('[role="button"][tabindex="0"], input:not(:disabled)')?.focus()
}, [index, questionKey])
const commit = (action: () => void | Promise<void>) => {
if (submitting.current || responding) return
submitting.current = true
setSubmitted(true)
setLocalError(null)
const owner = generation.current
void Promise.resolve().then(action).catch(cause => {
if (owner !== generation.current) return
submitting.current = false
setSubmitted(false)
setLocalError(cause instanceof Error ? cause.message : String(cause))
})
}
const currentIndex = Math.min(Math.max(index, 0), Math.max(questions.length - 1, 0)) const currentIndex = Math.min(Math.max(index, 0), Math.max(questions.length - 1, 0))
const current = questions[currentIndex] const current = questions[currentIndex]
const isLast = currentIndex === questions.length - 1 const isLast = currentIndex === questions.length - 1
@@ -191,8 +220,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
if (submitted || responding) return if (submitted || responding) return
const payload = getPayload(nextSelected, nextCustom, nextIncludeCustom) const payload = getPayload(nextSelected, nextCustom, nextIncludeCustom)
if (!payload) return if (!payload) return
setSubmitted(true) commit(() => onSelect?.(answerText(payload, labels), payload))
void Promise.resolve(onSelect?.(answerText(payload, labels), payload)).catch(() => setSubmitted(false))
} }
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -223,7 +251,8 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
if (isLast) { if (isLast) {
submit(nextSelected, nextCustom) submit(nextSelected, nextCustom)
} else { } else {
window.setTimeout(() => setIndex(value => Math.min(value + 1, questions.length - 1)), 120) if (advanceTimer.current) clearTimeout(advanceTimer.current)
advanceTimer.current = setTimeout(() => setIndex(value => Math.min(value + 1, questions.length - 1)), 120)
} }
} }
@@ -255,8 +284,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
})), })),
...(config.title ? { title: config.title } : {}) ...(config.title ? { title: config.title } : {})
} }
setSubmitted(true) commit(() => onSelect?.(answerText(payload, labels), payload))
void Promise.resolve(onSelect?.(answerText(payload, labels), payload)).catch(() => setSubmitted(false))
} else { } else {
setIndex(value => Math.min(value + 1, questions.length - 1)) setIndex(value => Math.min(value + 1, questions.length - 1))
} }
@@ -264,8 +292,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
const dismiss = () => { const dismiss = () => {
if (responding || submitted) return if (responding || submitted) return
if (onSkip) onSkip() commit(() => onSkip ? onSkip() : onDismiss?.())
else onDismiss?.()
} }
const showSingleSkip = !current.multiSelect && questions.length !== 1 && !isLast && !hasCustomText const showSingleSkip = !current.multiSelect && questions.length !== 1 && !isLast && !hasCustomText
@@ -274,6 +301,10 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
return ( return (
<section <section
ref={rootRef}
onKeyDown={event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() }
}}
className={[ className={[
'question-floating', 'question-floating',
confirmVariant && 'question-floating--confirm', confirmVariant && 'question-floating--confirm',
@@ -433,7 +464,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
onFocus={() => setCustomFocused(true)} onFocus={() => setCustomFocused(true)}
onBlur={() => setCustomFocused(false)} onBlur={() => setCustomFocused(false)}
onKeyDown={event => { onKeyDown={event => {
if (event.key === 'Enter' && !current.multiSelect && hasCustomText) { if (event.key === 'Enter' && !event.nativeEvent.isComposing && !current.multiSelect && hasCustomText) {
event.preventDefault() event.preventDefault()
advance() advance()
} }
@@ -442,7 +473,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
</div> </div>
)} )}
</div> </div>
{error && <div className="question-floating__error" role="alert">{error}</div>} {(error || localError) && <div className="question-floating__error" role="alert">{error || localError}</div>}
<footer className={[ <footer className={[
'question-floating__footer', 'question-floating__footer',
!current.multiSelect && 'question-floating__footer--single' !current.multiSelect && 'question-floating__footer--single'
@@ -1,3 +1,4 @@
import type { RuntimeProgress } from '@shared/types'
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react' import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
import { SOURCE_LOADING_TIPS } from './streaming-loading-tips' import { SOURCE_LOADING_TIPS } from './streaming-loading-tips'
import { conversationLabel } from './labels' import { conversationLabel } from './labels'
@@ -100,27 +101,33 @@ export interface StreamingFooterProps {
locale?: string locale?: string
/** 新任务启动阶段文案(原版 chat.prepare.<stage>);提供时替换「思考中」。 */ /** 新任务启动阶段文案(原版 chat.prepare.<stage>);提供时替换「思考中」。 */
prepareText?: string | null prepareText?: string | null
progress?: RuntimeProgress
reasoningOnly?: boolean
onCloseTips?: () => void
} }
export function StreamingFooter({ locale, prepareText }: StreamingFooterProps): React.JSX.Element { export function StreamingFooter({ locale, prepareText, progress, reasoningOnly = false, onCloseTips }: StreamingFooterProps): React.JSX.Element {
const resolvedLocale = locale ?? activeLocale() const resolvedLocale = locale ?? activeLocale()
const brandName = activeBrandName() const brandName = activeBrandName()
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale]) const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
// 设置 → 个性化「欢迎语」:关闭后不再轮播加载提示(服务端 token 用量与思考中状态保持不变)。 // 设置 → 个性化「欢迎语」:关闭后不再轮播加载提示(服务端 token 用量与思考中状态保持不变)。
const tipsEnabled = useSyncExternalStore(subscribeLoadingTips, loadingTipsEnabled, () => true) const tipsEnabled = useSyncExternalStore(subscribeLoadingTips, loadingTipsEnabled, () => true)
const [tip, setTip] = useState<{ text: string; index: number } | null>(null) const [tip, setTip] = useState<{ text: string; index: number } | null>(null)
const isPaused = useRef(false) const pause = useRef({ hovered: false, focused: false })
const [tipsClosed, setTipsClosed] = useState(false)
const showTips = tipsEnabled && !tipsClosed && !prepareText && progress?.phase !== 'preparing' && progress?.phase !== 'finishing'
useEffect(() => { useEffect(() => {
if (!tipsEnabled) { setTip(null); return } if (!showTips) { setTip(null); return }
return scheduleLoadingTips({ return scheduleLoadingTips({
tips, tips,
onTip: (text, index) => setTip({ text, index }), onTip: (text, index) => setTip({ text, index }),
isPaused: () => isPaused.current isPaused: () => isLoadingTipPaused(pause.current)
}) })
}, [tips, tipsEnabled]) }, [tips, showTips])
const thinkingLabel = prepareText ?? conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn') const thinkingLabel = resolveRuntimeStatusText(prepareText, progress, resolveLoadingTipLocale(resolvedLocale))
if (reasoningOnly && !prepareText && (!progress || progress.phase === 'thinking')) return <></>
return <div className="wb-cr-streaming-footer"> return <div className="wb-cr-streaming-footer">
<div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}> <div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}>
@@ -131,16 +138,36 @@ export function StreamingFooter({ locale, prepareText }: StreamingFooterProps):
</div> </div>
</div> </div>
</div> </div>
{tipsEnabled && tip && <span {showTips && tip && <span
className="_tipPhrase_13r4w_108 _trailingTipPhrase_13r4w_179 cb-loading-tip cb-loading-tip--trailing" className="_tipPhrase_13r4w_108 _trailingTipPhrase_13r4w_179 cb-loading-tip cb-loading-tip--trailing"
onMouseEnter={() => { isPaused.current = true }} tabIndex={0}
onMouseLeave={() => { isPaused.current = false }} onFocus={() => { pause.current.focused = true }}
onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) pause.current.focused = false }}
onMouseEnter={() => { pause.current.hovered = true }}
onMouseLeave={() => { pause.current.hovered = false }}
> >
<span className="_tipDivider_13r4w_121" aria-hidden="true">·</span> <span className="_tipDivider_13r4w_121" aria-hidden="true">·</span>
<span key={tip.index} className="_tipText_13r4w_126">{tip.text}</span> <span key={tip.index} className="_tipText_13r4w_126">{tip.text}</span>
<button type="button" className="cb-loading-tip__close" aria-label="关闭提示" onClick={() => { setTipsClosed(true); onCloseTips?.() }}>×</button>
</span>} </span>}
</div> </div>
</div> </div>
} }
export default StreamingFooter export default StreamingFooter
/** Only observed local phases; no fabricated cloud, network, credit or timeout state. */
export function resolveRuntimeStatusText(prepareText: string | null | undefined, progress: RuntimeProgress | undefined, locale: 'en' | 'zh' = 'zh'): string {
if (prepareText?.trim()) return prepareText
const en = locale === 'en'
if (progress?.phase === 'preparing') return progress.detail || (en ? 'Preparing' : '准备中')
if (progress?.phase === 'tool') return progress.toolName ? `${en ? 'Running' : '正在运行'} ${progress.toolName}` : (en ? 'Running tool' : '正在运行工具')
if (progress?.phase === 'waiting-approval') return en ? 'Waiting for approval' : '等待授权'
if (progress?.phase === 'waiting-answer') return en ? 'Waiting for your answer' : '等待回答'
if (progress?.phase === 'finishing') return en ? 'Finishing' : '正在收尾'
return conversationLabel('message.loading.thinking', {}, en ? 'en' : 'zh-cn')
}
export function isLoadingTipPaused(state: { hovered: boolean; focused: boolean }): boolean {
return state.hovered || state.focused
}
@@ -163,7 +163,7 @@ export function ToolCallRenderer({
const kind = getCapturedToolKind(tool.name) const kind = getCapturedToolKind(tool.name)
const body = resultBody(tool.result) const body = resultBody(tool.result)
if (kind === 'question') return withToolOutput(tool, <AskUserQuestion tool={tool} labels={{ if (kind === 'question') return withToolOutput((tool.metaData ?? tool.metadata)?.questionAnswer ? { ...tool, output: undefined } : tool, <AskUserQuestion tool={tool} labels={{
waiting: t('tool.question.waitingAnswer'), generating: t('tool.question.generating'), waiting: t('tool.question.waitingAnswer'), generating: t('tool.question.generating'),
title: t('tool.question.title'), cancelled: t('tool.question.cancelled'), failed: t('tool.question.askError'), title: t('tool.question.title'), cancelled: t('tool.question.cancelled'), failed: t('tool.question.askError'),
skipped: t('tool.question.skipped'), skippedAnswer: t('tool.question.skippedAnswer') skipped: t('tool.question.skipped'), skippedAnswer: t('tool.question.skippedAnswer')
@@ -258,7 +258,7 @@ function buildSourceSegments(
const segments: BuildSegment[] = [] const segments: BuildSegment[] = []
let foldable: CompletedFoldUnit[] = [] let foldable: CompletedFoldUnit[] = []
const flush = (): void => { const flush = (): void => {
if (foldable.length >= 2 || !turnFinished && foldable.some(unit => unit.part.type === 'tool')) segments.push({ kind: 'foldable', units: foldable }) if (foldable.length >= 2) segments.push({ kind: 'foldable', units: foldable })
else if (foldable.length === 1) segments.push({ kind: 'normal', units: foldable }) else if (foldable.length === 1) segments.push({ kind: 'normal', units: foldable })
foldable = [] foldable = []
} }
@@ -305,8 +305,8 @@ function buildSourceSegments(
const segment = segments[index] const segment = segments[index]
if (segment.kind === 'foldable') { if (segment.kind === 'foldable') {
if (segment.summaryGroup) { if (segment.summaryGroup) {
const containsToolCall = segment.units.some(unit => unit.part.type === 'tool') segment.summaryGroup.shouldFold = bodyTextAfter || turnFinished
segment.summaryGroup.shouldFold = bodyTextAfter || turnFinished || containsToolCall segment.summaryGroup.isRunning = !turnFinished && !bodyTextAfter
} }
continue continue
} }
@@ -338,3 +338,11 @@
.cb-plan-task--clickable { cursor: pointer; } .cb-plan-task--clickable { cursor: pointer; }
.cb-plan-task--clickable:hover { background: color-mix(in srgb, var(--conversation-text) 4%, transparent); } .cb-plan-task--clickable:hover { background: color-mix(in srgb, var(--conversation-text) 4%, transparent); }
.cb-plan-task--clickable:focus-visible { outline: 2px solid var(--conversation-accent); outline-offset: 2px; } .cb-plan-task--clickable:focus-visible { outline: 2px solid var(--conversation-accent); outline-offset: 2px; }
/* 10/21 alignment plan: answered QuestionView typography, not a tool summary. */
.ask-user-question--completed { background: var(--cb-content-background, var(--cb-gray-color)); border-radius: var(--yc-radius-2xl); padding: var(--yc-space-3) var(--yc-space-4); }
.ask-user-question__content { max-height: 22.5rem; overflow-y: auto; padding: var(--yc-space-3) 0 0; }
.question-answer-transcript { gap: var(--yc-space-4); }
.question-answer-transcript__question { color: var(--cb-text-tertiary, var(--cb-text-secondary)); font-size: var(--yc-font-size-5); font-weight: 400; line-height: 1.75; }
.question-answer-transcript__answer { color: var(--cb-text-primary); font-size: var(--yc-font-size-5); font-weight: 400; line-height: 1.75; }
.question-history-unpositioned { margin-block: var(--yc-space-3); }
@@ -0,0 +1,16 @@
/* Host permission card: no credit/risk claim is inferred from an approval. */
.conversation-intercept-card{box-sizing:border-box;width:100%;border:0.0625rem solid var(--cb-question-floating-card-border);background:var(--cb-question-floating-card-bg);border-radius:1.5rem;overflow:hidden}
.conversation-intercept-card__header{padding:1.25rem 1.5rem 0.75rem}
.conversation-intercept-card__title-row{display:flex;align-items:start;justify-content:space-between;gap:0.75rem}
.conversation-intercept-card__title{font-size:1rem;font-weight:500;line-height:1.5625rem;color:var(--wb-color-text-primary)}
.conversation-intercept-card__description,.conversation-intercept-card__rule{font-size:0.875rem;line-height:1.375rem;color:var(--wb-color-text-tertiary);overflow-wrap:anywhere;margin:0.5rem 0 0;white-space:pre-wrap}
.conversation-intercept-card__rule-action{flex:none;border:0;background:none;color:var(--wb-color-text-secondary);font:inherit;font-size:0.75rem;cursor:pointer}
.conversation-intercept-card__content{padding:0 0.75rem 1rem}
.conversation-intercept-card__option{display:flex;align-items:center;gap:0.5rem;width:100%;min-height:2.5rem;padding:0.5rem;border:0;border-radius:0.75rem;background:none;color:var(--cb-question-floating-text-primary);text-align:left;cursor:pointer;font:inherit}
.conversation-intercept-card__option:hover:not(:disabled),.conversation-intercept-card__option:focus-visible{background:var(--cb-question-floating-hover-bg)}
.conversation-intercept-card__option:disabled{opacity:.45;cursor:not-allowed}
.conversation-intercept-card__option-num{display:flex;align-items:center;justify-content:center;flex:none;width:1.5rem;height:1.5rem;border-radius:0.5rem;background:var(--cb-question-floating-number-bg);font-size:0.875rem}
.conversation-intercept-card__option-text{flex:1;font-size:0.875rem;line-height:1.375rem}
.conversation-intercept-card__option-arrow{width:1rem;height:1rem;opacity:0}
.conversation-intercept-card__option:hover .conversation-intercept-card__option-arrow,.conversation-intercept-card__option:focus-visible .conversation-intercept-card__option-arrow{opacity:1}
.conversation-intercept-card__error,.conversation-intercept-card__working,.conversation-intercept-card__decision{padding:0.75rem 1.5rem;font-size:0.8125rem;line-height:1.25rem;color:var(--wb-color-text-secondary)}
@@ -0,0 +1,30 @@
import type { ChatMessage, ToolCallRecord } from '@shared/types'
import type { QuestionAnswer } from '@shared/conversation'
import { getMessageParts } from '@shared/message-parts'
/** Use the recorded tool position; never guess a timestamp/order for old unlinked answers. */
export function projectQuestionAnswers(message: ChatMessage): {
tools: ToolCallRecord[]
positionedAnswers: Map<string, QuestionAnswer>
unpositioned: Array<{ id: string; answer: QuestionAnswer }>
} {
const tools = (message.tools ?? []).map(tool => ({ ...tool }))
const positions = new Set(getMessageParts(message).flatMap(part => part.type === 'tool' ? [part.toolId] : []))
const positionedAnswers = new Map<string, QuestionAnswer>()
const unpositioned: Array<{ id: string; answer: QuestionAnswer }> = []
const seen = new Set<string>()
for (const interaction of [...(message.interactions ?? [])].reverse()) {
if (interaction.kind !== 'question' || !interaction.answer) continue
const key = interaction.toolCallId || interaction.id
if (seen.has(key)) continue
seen.add(key)
const tool = tools.find(item => item.id === interaction.toolCallId)
if (positions.has(interaction.toolCallId)) {
positionedAnswers.set(interaction.toolCallId, interaction.answer)
if (tool) tool.metadata = { ...tool.metadata, questionAnswer: interaction.answer, questionSkipped: interaction.skipped ?? false }
} else {
unpositioned.unshift({ id: interaction.id, answer: interaction.answer })
}
}
return { tools, positionedAnswers, unpositioned }
}
@@ -3,3 +3,7 @@
* Runtime CSS identities: src-CBaV_A3S.css=2090ed21045c8f8b0fc388d76424c8751bf18fcd065cbbd5d0ef485163a2e36c (mismatch); contexts-BkjwYSZT.css=50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380 (match) * Runtime CSS identities: src-CBaV_A3S.css=2090ed21045c8f8b0fc388d76424c8751bf18fcd065cbbd5d0ef485163a2e36c (mismatch); contexts-BkjwYSZT.css=50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380 (match)
*/ */
._loadingWithCredit_ttf8p_463{align-items:center;width:min(100%,760px);margin-bottom:12px;display:flex;overflow:hidden}._loadingWithCredit_ttf8p_463>:first-child{flex:none;width:auto;min-width:max-content}._loadingWithCredit_ttf8p_463 .cb-credit-usage-text{white-space:nowrap;font-variant-numeric:tabular-nums;flex:none;justify-content:flex-end;min-width:96px}._loadingWithCredit_ttf8p_463 .cb-loading-tip--trailing{flex:auto;min-width:0;max-width:100%;overflow:hidden}._loadingMessage_13r4w_60{box-sizing:border-box;width:100%;padding:0 var(--cb-assistant-inline-padding,12px);contain:layout style;align-items:flex-start;line-height:1.75;display:flex}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69{width:100%;min-width:0;color:var(--cb-text-disabled,#0000004d);flex:auto;gap:6px;font-family:PingFang SC,-apple-system,BlinkMacSystemFont,Helvetica Neue,sans-serif;font-size:15px;font-weight:400;line-height:1.75;display:flex}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._loadingText_13r4w_81{white-space:nowrap;width:100%;min-width:0;max-width:100%;color:inherit;font-size:inherit;line-height:inherit;align-items:center;gap:0;display:flex;overflow:hidden}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._mainText_13r4w_94{white-space:nowrap;background:linear-gradient(110deg, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 0%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 35%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 55%, transparent) 50%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 65%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 100%);-webkit-text-fill-color:transparent;color:#0000;background-size:200% 100%;background-clip:text;flex:none;width:fit-content;animation:2.2s linear infinite _cb-shining-text-sweep_13r4w_1;display:inline-block;position:relative}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipPhrase_13r4w_108{width:auto;max-width:none;color:inherit;font-size:inherit;line-height:inherit;flex:auto;align-items:center;gap:8px;min-width:0;margin-left:8px;display:inline-flex}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipDivider_13r4w_121{color:var(--cb-border,#d0d0d0);user-select:none;flex-shrink:0}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipText_13r4w_126{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--cb-text-disabled,#ffffff4d);opacity:0;flex:auto;font-size:15px;font-style:normal;line-height:22px;text-decoration-line:none;animation:.32s ease-out forwards _cb-tip-fade-in_13r4w_1;overflow:hidden}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipDismissButton_13r4w_140{appearance:none;color:var(--cb-text-secondary,#0000008c);font:inherit;line-height:inherit;white-space:nowrap;cursor:pointer;opacity:0;pointer-events:none;background:0 0;border:0;flex:none;margin:0 0 0 auto;padding:0;transition:opacity .16s ease-out,color .16s ease-out}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipDismissButton_13r4w_140:hover{color:var(--cb-text-primary,#000000e0)}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipPhrase_13r4w_108:hover ._tipDismissButton_13r4w_140,._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipPhrase_13r4w_108:focus-within ._tipDismissButton_13r4w_140{opacity:1;pointer-events:auto}._tipPhrase_13r4w_108{color:var(--cb-text-disabled,#0000004d);flex:auto;align-items:center;gap:8px;min-width:0;margin-left:8px;font-family:PingFang SC,-apple-system,BlinkMacSystemFont,Helvetica Neue,sans-serif;font-size:15px;font-weight:400;line-height:1.75;display:inline-flex}._trailingTipPhrase_13r4w_179{flex:0 auto;margin-left:0}._trailingTipPhrase_13r4w_179 ._tipText_13r4w_126{flex:0 auto}._trailingTipPhrase_13r4w_179 ._tipDismissButton_13r4w_140{margin-left:8px}._tipDivider_13r4w_121{color:var(--cb-border,#d0d0d0);user-select:none;flex-shrink:0}._tipText_13r4w_126{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--cb-text-disabled,#ffffff4d);opacity:0;flex:auto;font-size:15px;font-style:normal;line-height:22px;text-decoration-line:none;animation:.32s ease-out forwards _cb-tip-fade-in_13r4w_1;overflow:hidden}._tipDismissButton_13r4w_140{appearance:none;color:var(--cb-text-secondary,#0000008c);font:inherit;line-height:inherit;white-space:nowrap;cursor:pointer;opacity:0;pointer-events:none;background:0 0;border:0;flex:none;margin:0 0 0 auto;padding:0;transition:opacity .16s ease-out,color .16s ease-out}._tipDismissButton_13r4w_140:hover{color:var(--cb-text-primary,#000000e0)}._tipPhrase_13r4w_108:hover ._tipDismissButton_13r4w_140,._tipPhrase_13r4w_108:focus-within ._tipDismissButton_13r4w_140{opacity:1;pointer-events:auto}@keyframes _cb-shining-text-sweep_13r4w_1{0%{background-position:200% 0}to{background-position:-200% 0}}@keyframes _cb-tip-fade-in_13r4w_1{0%{opacity:0}to{opacity:1}}.conversation-streaming-footer>.assistant-message{flex:none;width:auto;min-width:max-content}.conversation-streaming-footer>.cb-loading-tip--trailingLine truncated ._loadingWithCredit_ttf8p_463{align-items:center;width:min(100%,760px);margin-bottom:12px;display:flex;overflow:hidden}._loadingWithCredit_ttf8p_463>:first-child{flex:none;width:auto;min-width:max-content}._loadingWithCredit_ttf8p_463 .cb-credit-usage-text{white-space:nowrap;font-variant-numeric:tabular-nums;flex:none;justify-content:flex-end;min-width:96px}._loadingWithCredit_ttf8p_463 .cb-loading-tip--trailing{flex:auto;min-width:0;max-width:100%;overflow:hidden}._loadingMessage_13r4w_60{box-sizing:border-box;width:100%;padding:0 var(--cb-assistant-inline-padding,12px);contain:layout style;align-items:flex-start;line-height:1.75;display:flex}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69{width:100%;min-width:0;color:var(--cb-text-disabled,#0000004d);flex:auto;gap:6px;font-family:PingFang SC,-apple-system,BlinkMacSystemFont,Helvetica Neue,sans-serif;font-size:15px;font-weight:400;line-height:1.75;display:flex}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._loadingText_13r4w_81{white-space:nowrap;width:100%;min-width:0;max-width:100%;color:inherit;font-size:inherit;line-height:inherit;align-items:center;gap:0;display:flex;overflow:hidden}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._mainText_13r4w_94{white-space:nowrap;background:linear-gradient(110deg, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 0%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 35%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 55%, transparent) 50%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 65%, color-mix(in srgb, var(--cb-text-primary,#d2d3e0) 26%, transparent) 100%);-webkit-text-fill-color:transparent;color:#0000;background-size:200% 100%;background-clip:text;flex:none;width:fit-content;animation:2.2s linear infinite _cb-shining-text-sweep_13r4w_1;display:inline-block;position:relative}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipPhrase_13r4w_108{width:auto;max-width:none;color:inherit;font-size:inherit;line-height:inherit;flex:auto;align-items:center;gap:8px;min-width:0;margin-left:8px;display:inline-flex}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipDivider_13r4w_121{color:var(--cb-border,#d0d0d0);user-select:none;flex-shrink:0}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipText_13r4w_126{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--cb-text-disabled,#ffffff4d);opacity:0;flex:auto;font-size:15px;font-style:normal;line-height:22px;text-decoration-line:none;animation:.32s ease-out forwards _cb-tip-fade-in_13r4w_1;overflow:hidden}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipDismissButton_13r4w_140{appearance:none;color:var(--cb-text-secondary,#0000008c);font:inherit;line-height:inherit;white-space:nowrap;cursor:pointer;opacity:0;pointer-events:none;background:0 0;border:0;flex:none;margin:0 0 0 auto;padding:0;transition:opacity .16s ease-out,color .16s ease-out}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipDismissButton_13r4w_140:hover{color:var(--cb-text-primary,#000000e0)}._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipPhrase_13r4w_108:hover ._tipDismissButton_13r4w_140,._loadingMessage_13r4w_60 ._loadingContent_13r4w_69 ._tipPhrase_13r4w_108:focus-within ._tipDismissButton_13r4w_140{opacity:1;pointer-events:auto}._tipPhrase_13r4w_108{color:var(--cb-text-disabled,#0000004d);flex:auto;align-items:center;gap:8px;min-width:0;margin-left:8px;font-family:PingFang SC,-apple-system,BlinkMacSystemFont,Helvetica Neue,sans-serif;font-size:15px;font-weight:400;line-height:1.75;display:inline-flex}._trailingTipPhrase_13r4w_179{flex:0 auto;margin-left:0}._trailingTipPhrase_13r4w_179 ._tipText_13r4w_126{flex:0 auto}._trailingTipPhrase_13r4w_179 ._tipDismissButton_13r4w_140{margin-left:8px}._tipDivider_13r4w_121{color:var(--cb-border,#d0d0d0);user-select:none;flex-shrink:0}._tipText_13r4w_126{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--cb-text-disabled,#ffffff4d);opacity:0;flex:auto;font-size:15px;font-style:normal;line-height:22px;text-decoration-line:none;animation:.32s ease-out forwards _cb-tip-fade-in_13r4w_1;overflow:hidden}._tipDismissButton_13r4w_140{appearance:none;color:var(--cb-text-secondary,#0000008c);font:inherit;line-height:inherit;white-space:nowrap;cursor:pointer;opacity:0;pointer-events:none;background:0 0;border:0;flex:none;margin:0 0 0 auto;padding:0;transition:opacity .16s ease-out,color .16s ease-out}._tipDismissButton_13r4w_140:hover{color:var(--cb-text-primary,#000000e0)}._tipPhrase_13r4w_108:hover ._tipDismissButton_13r4w_140,._tipPhrase_13r4w_108:focus-within ._tipDismissButton_13r4w_140{opacity:1;pointer-events:auto}@keyframes _cb-shining-text-sweep_13r4w_1{0%{background-position:200% 0}to{background-position:-200% 0}}@keyframes _cb-tip-fade-in_13r4w_1{0%{opacity:0}to{opacity:1}}.conversation-streaming-footer>.assistant-message{flex:none;width:auto;min-width:max-content}.conversation-streaming-footer>.cb-loading-tip--trailingLine truncated
/* Accessible dismissal belongs to the tip only, never the running status. */
.cb-loading-tip__close { background: transparent; border: 0; color: inherit; cursor: pointer; padding: 0 4px; font: inherit; }
.cb-loading-tip__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
@@ -63,6 +63,7 @@ export type QuestionOption =
id?: string id?: string
question?: string question?: string
option?: string option?: string
label?: string
description?: string description?: string
hint?: string hint?: string
} }
+14 -1
View File
@@ -58,6 +58,19 @@ export interface QuestionAnswer {
questions: Array<{ id: string; question: string; answers: string[] }> questions: Array<{ id: string; question: string; answers: string[] }>
} }
export interface PermissionOption { optionId: string; name: string; kind: string } export interface PermissionOption { optionId: string; name: string; kind: string }
export interface ApprovalDetails {
requestKind: 'permission' | 'rule-intercept'
toolName: string
command?: string
target?: string
reason?: string
/** Only present when the upstream supplies actual matched-rule identity/details. */
rule?: { id?: string; description: string }
allowScope: 'once' | 'session'
defaultToNo?: boolean
suggestedRules?: string[]
sessionUnavailableReason?: string
}
export type InteractionStatus = 'pending' | 'responding' | 'answered' | 'rejected' | 'cancelled' | 'error' export type InteractionStatus = 'pending' | 'responding' | 'answered' | 'rejected' | 'cancelled' | 'error'
interface InteractionBase { interface InteractionBase {
id: string id: string
@@ -69,7 +82,7 @@ interface InteractionBase {
} }
export type ChatInteraction = export type ChatInteraction =
| (InteractionBase & { kind: 'question'; questions: QuestionItem[]; answer?: QuestionAnswer; skipped?: boolean }) | (InteractionBase & { kind: 'question'; questions: QuestionItem[]; answer?: QuestionAnswer; skipped?: boolean })
| (InteractionBase & { kind: 'approval'; requestId: string; options: PermissionOption[]; selectedOptionId?: string; scope?: string }) | (InteractionBase & { kind: 'approval'; requestId: string; options: PermissionOption[]; selectedOptionId?: string; scope?: string; details?: ApprovalDetails })
export type InteractionResponse = export type InteractionResponse =
| { kind: 'question'; answer: QuestionAnswer; skipped?: boolean } | { kind: 'question'; answer: QuestionAnswer; skipped?: boolean }
| { kind: 'approval'; optionId: string } | { kind: 'approval'; optionId: string }
+11 -4
View File
@@ -13,6 +13,11 @@ export function getMessageParts(message: ChatMessage): MessagePart[] {
/** 主进程持久化和渲染进程使用同一事件归约,避免重载前后内容顺序不同。 */ /** 主进程持久化和渲染进程使用同一事件归约,避免重载前后内容顺序不同。 */
export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): ChatMessage { export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): ChatMessage {
switch (event.type) { switch (event.type) {
case 'progress': {
if (message.requestId && message.requestId !== event.runId) return message
if (message.status && message.status !== 'streaming') return message
return { ...message, runtimeProgress: event.progress ?? undefined }
}
case 'usage': { case 'usage': {
const contextUsage = normalizeContextUsageSnapshot({ usage: event.usage, contextWindow: event.contextWindow, const contextUsage = normalizeContextUsageSnapshot({ usage: event.usage, contextWindow: event.contextWindow,
source:event.source,sampledAt:event.sampledAt,requestId:event.runId,modelId:event.modelId }) source:event.source,sampledAt:event.sampledAt,requestId:event.runId,modelId:event.modelId })
@@ -83,7 +88,8 @@ export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): Cha
parts.push({ type: 'tool', toolId: tool.id }) parts.push({ type: 'tool', toolId: tool.id })
} }
} }
return { ...message, content: event.content, tools: event.tools, parts, status: 'done', ...terminalDuration(event) } const interactions = message.interactions?.map(item => ['pending', 'responding', 'error'].includes(item.status) ? { ...item, status: 'cancelled' as const } : item)
return { ...message, content: event.content, tools: event.tools, parts, interactions, runtimeProgress: undefined, status: 'done', ...terminalDuration(event) }
} }
case 'cancelled': case 'cancelled':
case 'error': { case 'error': {
@@ -91,18 +97,19 @@ export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): Cha
const tools = message.tools?.map((tool): ToolCallRecord => const tools = message.tools?.map((tool): ToolCallRecord =>
tool.status === 'running' ? { ...tool, status } : tool tool.status === 'running' ? { ...tool, status } : tool
) )
const interactions = message.interactions?.map((item) => item.status === 'pending' || item.status === 'responding' ? { ...item, status: 'cancelled' as const } : item) const interactions = message.interactions?.map((item) => ['pending', 'responding', 'error'].includes(item.status) ? { ...item, status: 'cancelled' as const } : item)
return { ...message, tools, interactions, parts: message.parts, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) } return { ...message, tools, interactions, parts: message.parts, runtimeProgress: undefined, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) }
} }
default: default:
return message return message
} }
} }
function terminalDuration(event: ChatEvent): Pick<ChatMessage, 'turnDurationMs' | 'turnStartedAt'> { function terminalDuration(event: ChatEvent): Pick<ChatMessage, 'turnDurationMs' | 'turnStartedAt' | 'turnFinishedAt'> {
return { return {
...(typeof event.turnDurationMs === 'number' && Number.isFinite(event.turnDurationMs) && event.turnDurationMs >= 0 ...(typeof event.turnDurationMs === 'number' && Number.isFinite(event.turnDurationMs) && event.turnDurationMs >= 0
? { turnDurationMs: event.turnDurationMs } : {}), ? { turnDurationMs: event.turnDurationMs } : {}),
...(typeof event.turnFinishedAt === 'number' && Number.isFinite(event.turnFinishedAt) ? { turnFinishedAt: event.turnFinishedAt } : {}),
turnStartedAt: undefined turnStartedAt: undefined
} }
} }
+10
View File
@@ -0,0 +1,10 @@
import type { CustomModelProfile } from './models'
/** Resolve only real registry entries; display and the next request share this order. */
export function resolveSelectedModel(
models: readonly CustomModelProfile[], preferredId?: string | null, defaultId?: string | null
): CustomModelProfile | null {
return models.find(model => model.id === preferredId)
?? models.find(model => model.id === defaultId)
?? models[0] ?? null
}
+14 -1
View File
@@ -34,6 +34,8 @@ export interface ChatMessage {
/** Independent local counts of one prepared provider input; never a split of billed usage. */ /** Independent local counts of one prepared provider input; never a split of billed usage. */
contextBreakdown?: ContextUsageBreakdown contextBreakdown?: ContextUsageBreakdown
interactions?: ChatInteraction[] interactions?: ChatInteraction[]
/** Live, observed runtime state; cleared on every terminal event. */
runtimeProgress?: RuntimeProgress
requestId?: string requestId?: string
/** Last top-level Claude chain entry for this assistant turn, used only as a fork point. */ /** Last top-level Claude chain entry for this assistant turn, used only as a fork point. */
claudeEntryId?: string claudeEntryId?: string
@@ -45,6 +47,8 @@ export interface ChatMessage {
turnStartedAt?: number turnStartedAt?: number
/** Measured user-send to terminal-event duration, used by the source completed fold. */ /** Measured user-send to terminal-event duration, used by the source completed fold. */
turnDurationMs?: number turnDurationMs?: number
/** Actual observed terminal timestamp, absent for legacy history. */
turnFinishedAt?: number
/** 本回合的终态 */ /** 本回合的终态 */
status?: 'streaming' | 'done' | 'error' | 'cancelled' status?: 'streaming' | 'done' | 'error' | 'cancelled'
error?: string error?: string
@@ -215,9 +219,18 @@ export interface MemorySettingsView extends MemoryPreferences {
export type MemoryPreferencesPatch = Partial<MemoryPreferences> export type MemoryPreferencesPatch = Partial<MemoryPreferences>
/** Only stages observed by this host. No synthetic cloud/network/credit states. */
export interface RuntimeProgress {
source: 'preparation' | 'agent' | 'interaction'
phase: 'preparing' | 'thinking' | 'tool' | 'waiting-approval' | 'waiting-answer' | 'finishing'
toolName?: string
detail?: string
}
/** 主进程 → 渲染进程的流式事件 */ /** 主进程 → 渲染进程的流式事件 */
export type ChatEvent = { sessionId?: string; turnDurationMs?: number; sequence?: number } & ( export type ChatEvent = { sessionId?: string; turnDurationMs?: number; turnFinishedAt?: number; sequence?: number } & (
| { runId: string; type: 'started'; messageId: string } | { runId: string; type: 'started'; messageId: string }
| { runId: string; type: 'progress'; progress: RuntimeProgress | null }
| { runId: string; type: 'text'; delta: string } | { runId: string; type: 'text'; delta: string }
| { runId: string; type: 'part'; part: Exclude<MessagePart, { type: 'text' } | { type: 'tool' }> } | { runId: string; type: 'part'; part: Exclude<MessagePart, { type: 'text' } | { type: 'tool' }> }
| { runId: string; type: 'interaction'; interaction: ChatInteraction } | { runId: string; type: 'interaction'; interaction: ChatInteraction }