feat: align conversation state, composer and scoped approvals
This commit is contained in:
1 parent
771ca540f7
commit
308b98df23
61 files changed
+1696
-287
No files matched your search
@@ -6,6 +6,7 @@
|
||||
"author": "YCode",
|
||||
"main": "./out/main/index.mjs",
|
||||
"scripts": {
|
||||
"test:alignment": "node scripts/run-alignment-regression.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: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' })
|
||||
|
||||
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} />)
|
||||
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'
|
||||
}
|
||||
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/)
|
||||
|
||||
const footerCss = readFileSync(resolve('src/renderer/src/components/assistant-message-details.css'), 'utf8')
|
||||
|
||||
@@ -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;`}))
|
||||
}}]})
|
||||
@@ -1,9 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
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 { 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 {
|
||||
return {
|
||||
@@ -73,6 +74,7 @@ function activeRunSnapshot(
|
||||
}
|
||||
|
||||
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({
|
||||
sessions: [session],
|
||||
currentSessionId: session.id,
|
||||
@@ -85,7 +87,7 @@ function resetStore(): void {
|
||||
sendingSessions: [],
|
||||
running: null,
|
||||
sending: false,
|
||||
loading: false,
|
||||
loading: false, loadError: null,
|
||||
error: null,
|
||||
draftContext: {},
|
||||
draftId: 0
|
||||
@@ -188,7 +190,7 @@ async function testFailedCreatedSessionIsRemoved(): Promise<void> {
|
||||
assert.equal(created.id in state.sessionContextWindow, 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.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.match(state.error ?? '', /send rejected after create/)
|
||||
}
|
||||
@@ -326,6 +328,8 @@ async function testModelChangePreservesFrozenUsage(): Promise<void> {
|
||||
assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used,
|
||||
'a rejected model change must leave the existing usage sample intact')
|
||||
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> {
|
||||
@@ -580,7 +584,7 @@ async function testHydrationBuffersKnownRunAndFiltersByWatermark(): Promise<void
|
||||
installFakeWindow(async () => sendResult('unused'))
|
||||
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({
|
||||
sessionId: session.id,
|
||||
runId: 'run-watermark',
|
||||
@@ -648,7 +652,7 @@ async function testTerminalActiveSnapshotDoesNotCreateRunning(): Promise<void> {
|
||||
if (status === 'error') assistant.error = 'snapshot failed'
|
||||
window.ycode.chat.activeRun = async () => snapshot
|
||||
|
||||
await useChatStore.getState().selectSession(session.id)
|
||||
await useChatStore.getState().selectSession(session.id, { reload: true })
|
||||
|
||||
const state = useChatStore.getState()
|
||||
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})}
|
||||
@@ -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})}
|
||||
@@ -95,10 +95,10 @@ const activePrefixSummary = buildCompletedFoldModel([
|
||||
], { turnFinished: false })
|
||||
assert.ok(activePrefixSummary)
|
||||
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.segments.filter(segment => segment.kind === 'unit' && segment.unit.kind === 'summary').length, 2)
|
||||
assert.equal(activePrefixSummary.summaryGroups[0].isRunning, false)
|
||||
assert.equal(activePrefixSummary.segments.filter(segment => segment.kind === 'unit' && segment.unit.kind === 'summary').length, 1)
|
||||
|
||||
const initialInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{
|
||||
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' } }
|
||||
]
|
||||
}} />)
|
||||
assert.equal((initialInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 1)
|
||||
assert.match(initialInspectionMarkup, /读取 接手状态-2026-10-15/)
|
||||
assert.equal((streamingInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 1,
|
||||
'new tool calls replace the current activity row instead of nesting under the first action')
|
||||
assert.match(streamingInspectionMarkup, /正在查看文件列表 src/)
|
||||
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')
|
||||
assert.equal((initialInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 0, 'unfinished tail is not a summary')
|
||||
assert.equal((streamingInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 0)
|
||||
assert.match(streamingInspectionMarkup, /已处理 1s/)
|
||||
assert.match(streamingInspectionMarkup, /data-tool-name="list_dir"[^]*data-tool-name="read_file"/)
|
||||
assert.match(streamingInspectionMarkup, /<strong>Listing project, reading h/)
|
||||
|
||||
const completedInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{
|
||||
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' }],
|
||||
tools: [{ id: 'list-src', name: 'list_dir', status: 'running', intent: 'Inspecting source layout', args: { path: 'src' }, argsRaw: '{"path":"src"}' }]
|
||||
}} />)
|
||||
assert.match(streamingToolIntentMarkup, /正在查看文件列表 src/,
|
||||
'the one live activity row uses the localized summary for the current tool call')
|
||||
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')
|
||||
assert.doesNotMatch(streamingToolIntentMarkup, /cr-streaming-activity/, 'single live tool stays independent')
|
||||
assert.match(streamingToolIntentMarkup, /data-tool-name="list_dir"/)
|
||||
for (const status of ['cancelled', 'error'] as const) {
|
||||
const terminalMarkup = renderToStaticMarkup(<MessageBubble message={{
|
||||
id: `terminal-${status}`, role: 'assistant', status, createdAt: 1, turnDurationMs: 65_000,
|
||||
|
||||
@@ -22,7 +22,7 @@ function check(label: string, condition: boolean, detail = ''): void {
|
||||
|
||||
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.
|
||||
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> {
|
||||
|
||||
@@ -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 <safe>'))
|
||||
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)})
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { nextRequestSettings } from '../src/main/agent/request-settings'
|
||||
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 { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types'
|
||||
import type { SessionContext } from '../src/shared/workspaces'
|
||||
@@ -181,6 +182,7 @@ function resetStore(
|
||||
messages: ChatMessage[] = [],
|
||||
draftContext: SessionContext = {}
|
||||
): 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({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
@@ -193,7 +195,7 @@ function resetStore(
|
||||
sendingSessions: [],
|
||||
running: null,
|
||||
sending: false,
|
||||
loading: false,
|
||||
loading: false, loadError: null,
|
||||
error: null,
|
||||
draftContext,
|
||||
draftId: 0
|
||||
|
||||
@@ -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})}
|
||||
@@ -106,7 +106,7 @@ const streamingMessage = (content: string, parts: ChatMessage['parts']): ChatMes
|
||||
})
|
||||
const emptyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('', [])} />)
|
||||
assert.match(emptyStream, /wb-cr-streaming-footer/)
|
||||
assert.match(emptyStream, /思考中/)
|
||||
assert.match(emptyStream, /思考中|Thinking/)
|
||||
assert.doesNotMatch(emptyStream, /bubble__cursor/)
|
||||
|
||||
const bodyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('partial answer', [{ type: 'text', text: 'partial answer' }])} />)
|
||||
|
||||
@@ -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) })
|
||||
@@ -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' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
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 = ''
|
||||
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('TASK_MENU_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 150000)
|
||||
child.once('exit', async code => {
|
||||
child.once('exit', async (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code) console.error(output)
|
||||
process.exitCode = code ? 1 : 0
|
||||
console.log(JSON.stringify({ directory, exitCode: code, signal }))
|
||||
if (code !== 0) console.error(output)
|
||||
process.exitCode = code === 0 ? 0 : 1
|
||||
})
|
||||
})()
|
||||
} else {
|
||||
@@ -163,22 +163,22 @@ if (!process.versions.electron) {
|
||||
await step('archive-confirm')
|
||||
await read(openMenu); await wait(menuSource, 'menu for archive')
|
||||
await read(clickItemByLabel('归档'))
|
||||
await wait(`!!document.querySelector('.task-confirm-dialog[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}})()`)
|
||||
await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="archive"]')`, 'archive confirmation')
|
||||
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 confirmation points to the in-product archive entry', archiveConfirm.desc, '确认将该任务归档吗?归档后可在侧栏「已归档」中查看并恢复。')
|
||||
check('archive confirmation does not reintroduce removed data management', archiveConfirm.desc.includes('数据管理'), false)
|
||||
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
|
||||
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 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 read(`document.querySelector('.task-confirm-dialog__cancel').click()`)
|
||||
await wait(`!document.querySelector('.task-confirm-dialog')`, 'archive cancellation closes')
|
||||
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
|
||||
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)
|
||||
|
||||
await read(openMenu); await wait(menuSource, 'menu for archive confirm')
|
||||
await read(clickItemByLabel('归档'))
|
||||
await wait(`!!document.querySelector('.task-confirm-dialog__confirm')`, 'archive confirmation confirm')
|
||||
await read(`document.querySelector('.task-confirm-dialog__confirm').click()`)
|
||||
await wait(`!!document.querySelector('.task-popconfirm__confirm')`, 'archive confirmation confirm')
|
||||
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
|
||||
await wait(`document.querySelectorAll('[data-session-id]').length===1`, 'archive completes after confirmation')
|
||||
const visibleAfterArchive = await read(`document.querySelectorAll('[data-session-id]').length`)
|
||||
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)`)
|
||||
await read(openMenu); await wait(menuSource, 'menu for delete')
|
||||
await read(clickItemByLabel('删除任务'))
|
||||
await wait(`!!document.querySelector('.task-confirm-dialog[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')}})()`)
|
||||
await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="delete"]')`, 'delete confirmation')
|
||||
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 description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
|
||||
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
|
||||
check('idle delete is not marked as running', deleteConfirm.running, null)
|
||||
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')
|
||||
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)
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
tool,
|
||||
type CanUseTool,
|
||||
type PermissionResult,
|
||||
type PermissionUpdate,
|
||||
type Query,
|
||||
type SDKMessage,
|
||||
type SDKUserMessage
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import { z } from 'zod'
|
||||
import { sessionGrantProposal, sessionGrantRules, rememberSessionGrant, clearSessionGrants } from './session-grants'
|
||||
import type { AgentMode, PermissionMode } from '@shared/conversation'
|
||||
import type { ChatEvent, ContextUsageBreakdown, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
|
||||
import type { ResolvedModel } from '@shared/models'
|
||||
@@ -327,6 +327,7 @@ export class ClaudeKernel {
|
||||
private consuming: Promise<void> | null = null
|
||||
private active: ActiveTurn | null = null
|
||||
private starting = false
|
||||
private permissionUpdates: Promise<void> = Promise.resolve()
|
||||
private draining: Promise<void> | null = null
|
||||
private contextQuery: ReturnType<Query['getContextUsage']> | null = null
|
||||
private lastRequestId?: string
|
||||
@@ -394,6 +395,17 @@ export class ClaudeKernel {
|
||||
canUseTool: this.canUseTool,
|
||||
hooks: {
|
||||
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',
|
||||
hooks: [async (input) => {
|
||||
const active = await this.waitForSegment()
|
||||
@@ -503,6 +515,20 @@ export class ClaudeKernel {
|
||||
void consuming.finally(() => {
|
||||
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> {
|
||||
@@ -540,13 +566,14 @@ export class ClaudeKernel {
|
||||
}
|
||||
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 模式只允许读取资料、搜索和提问,不能修改文件或执行命令。' }
|
||||
}
|
||||
if (this.agentMode === 'plan' && !isReadOnlyCall(toolName, input)) {
|
||||
if (this.agentMode === 'plan' && !isModeAllowedCall('plan', toolName, input)) {
|
||||
return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' }
|
||||
}
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } })
|
||||
const questions = questionsFromInput(input)
|
||||
if (!questions.length) return { behavior: 'deny', message: '问题内容为空。' }
|
||||
const response = await active.request.interactions.question(
|
||||
@@ -562,33 +589,67 @@ export class ClaudeKernel {
|
||||
for (const answer of response.answer.questions) {
|
||||
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 } }
|
||||
}
|
||||
if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
const grant = !options.suppressAlwaysAllowRule ? sessionGrantProposal(options.suggestions) : undefined
|
||||
const optionsForApproval = [
|
||||
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
|
||||
...(!options.suppressAlwaysAllowRule && options.suggestions?.length
|
||||
? [{ optionId: 'allow-always', name: '始终允许', kind: 'allow_always' }]
|
||||
...(grant
|
||||
? [{ optionId: 'allow-always', name: '本次会话内始终允许', kind: 'allow_always' }]
|
||||
: []),
|
||||
{ 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(
|
||||
options.toolUseID,
|
||||
options.title ?? options.displayName ?? `运行 ${toolName}`,
|
||||
optionsForApproval,
|
||||
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') {
|
||||
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 {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
...(response.optionId === 'allow-always' && options.suggestions?.length
|
||||
? { updatedPermissions: options.suggestions as PermissionUpdate[] }
|
||||
...(response.optionId === 'allow-always' && grant
|
||||
? { updatedPermissions: grant.updates }
|
||||
: {})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -654,6 +715,7 @@ export class ClaudeKernel {
|
||||
if (!active) return
|
||||
const index = typeof event.index === 'number' ? event.index : 0
|
||||
if (event.type === 'message_start') {
|
||||
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
|
||||
active.assistantSequence++
|
||||
const message = event.message 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 ?? {}),
|
||||
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.toolOrder.push(record)
|
||||
active.request.emit({ runId: active.request.runId, type: 'tool_call', call: { ...record } })
|
||||
@@ -769,6 +832,7 @@ export class ClaudeKernel {
|
||||
if (value.type === 'result') {
|
||||
const active = this.active
|
||||
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.
|
||||
const usage = active.streamUsage
|
||||
if (usage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
|
||||
@@ -891,6 +955,7 @@ export class ClaudeKernel {
|
||||
originalReject(error)
|
||||
}
|
||||
this.active = active
|
||||
request.emit({ runId: request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
|
||||
this.lastRequestId = request.runId
|
||||
this.lastGroupKey = request.userMessageId
|
||||
try {
|
||||
@@ -1037,6 +1102,11 @@ function mustAskInFullAccess(options: Parameters<CanUseTool>[2]): boolean {
|
||||
(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 {
|
||||
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
|
||||
@@ -1116,6 +1186,7 @@ export async function disposeClaudeKernels(): Promise<void> {
|
||||
shuttingDown = true
|
||||
const entries = [...kernels.values()]
|
||||
kernels.clear()
|
||||
clearSessionGrants()
|
||||
sessionViews.clear()
|
||||
await Promise.all(entries.map(({ kernel }) => kernel.close().catch(() => undefined)))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
const createCheckpoint = () => createMessageCheckpoint(
|
||||
(message) => sessions.patchMessage(sessionId, message.id, {
|
||||
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,
|
||||
claudeEntryId: message.claudeEntryId
|
||||
}),
|
||||
@@ -249,7 +249,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
const emit = (event: ChatEvent): void => {
|
||||
const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
|
||||
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 }
|
||||
if (observedEvent.type === 'usage') {
|
||||
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 () => {
|
||||
try {
|
||||
emit({ runId, type: 'progress', progress: { source: 'preparation', phase: 'preparing' } })
|
||||
await sessions.setClaudeFork(sessionId, forkAt, forceNew)
|
||||
if (payload.action) await releaseClaudeKernel(sessionId, true)
|
||||
const kernel = await getClaudeKernel({
|
||||
@@ -326,7 +327,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id,
|
||||
onSegmentComplete: async result => {
|
||||
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
|
||||
await checkpoint.finish(assistant)
|
||||
},
|
||||
@@ -371,7 +372,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
controller.abort()
|
||||
try {
|
||||
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)
|
||||
}
|
||||
await checkpoint.finish(assistant)
|
||||
|
||||
@@ -77,6 +77,24 @@ function decryptKey(stored: StoredConfig): { key: string; storage: 'encrypted' |
|
||||
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> {
|
||||
const stored = await load()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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'
|
||||
|
||||
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>()
|
||||
|
||||
function abortError(): Error { return Object.assign(new Error('交互已取消'), { name: 'AbortError' }) }
|
||||
|
||||
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
|
||||
if (activeSignal.aborted) throw abortError()
|
||||
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' } })
|
||||
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 })
|
||||
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('问答响应类型不匹配')
|
||||
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 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('审批响应类型不匹配')
|
||||
return response
|
||||
}
|
||||
@@ -45,9 +45,9 @@ export function createInteractionBroker(runId: string, signal: AbortSignal, emit
|
||||
export type InteractionBroker = ReturnType<typeof createInteractionBroker>
|
||||
|
||||
/** 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)
|
||||
if (!entry || entry.request.runId !== runId) return false
|
||||
if (!entry || entry.applying || entry.request.runId !== runId) return false
|
||||
const request = entry.request
|
||||
if (request.kind !== response.kind) throw new Error('交互响应类型不匹配')
|
||||
let resolved: ChatInteraction
|
||||
@@ -73,9 +73,24 @@ export function respondToInteraction(runId: string, id: string, response: Intera
|
||||
finalResponse = { ...response, answer }
|
||||
resolved = { ...request, answer, skipped: response.skipped, status: 'answered' }
|
||||
} else throw new Error('交互响应类型不匹配')
|
||||
const finish = (): boolean => {
|
||||
if (pending.get(id) !== entry) return false
|
||||
pending.delete(id)
|
||||
entry.detach()
|
||||
emit({ runId, type: 'interaction', interaction: resolved })
|
||||
entry.resolve(finalResponse)
|
||||
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
@@ -306,9 +306,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
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('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)
|
||||
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 { filename, bytes } = decodeDownload(payload)
|
||||
|
||||
+8
-3
@@ -9,7 +9,7 @@ import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpo
|
||||
import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection'
|
||||
import { modelMetadata } from './model-metadata'
|
||||
import { resolveModelTokenLimits } from '@shared/model-token-limits'
|
||||
import { getGatewayConfig } from './config'
|
||||
import { getGatewayConfig, getLegacyModelConfig } from './config'
|
||||
|
||||
interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
|
||||
apiKeyEnc?: string
|
||||
@@ -75,7 +75,12 @@ async function readRegistry(persistMigration: boolean): Promise<StoredRegistry>
|
||||
try { raw = await fs.readFile(filePath(), 'utf8') }
|
||||
catch (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 = {
|
||||
id: 'legacy-default', name: legacy.model, providerId: 'custom',
|
||||
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 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)
|
||||
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 }
|
||||
await persist(updated)
|
||||
return registryView(updated)
|
||||
|
||||
@@ -7,11 +7,12 @@ import * as workspaces from './workspaces'
|
||||
import { agentModeContextPatch, thinkingLevel } from './agent/mode-policy'
|
||||
export async function createManagedSession(projects: ProjectRepository, title?: string, context: SessionContext = {}) {
|
||||
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
|
||||
if (project?.workspaceId && context.workspaceId && context.workspaceId !== project.workspaceId) throw new Error('项目任务必须使用项目所属工作空间')
|
||||
const workspaceId = project?.workspaceId ?? context.workspaceId ?? (await workspaces.listWorkspaces()).selectedWorkspaceId ?? undefined
|
||||
const cwd = await workspaces.workspaceRoot(workspaceId)
|
||||
const modelId = context.modelId ?? (await models.listModels()).selectedModelId ?? undefined
|
||||
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}`
|
||||
}
|
||||
|
||||
function MessageTime({ time }: { time: number }): React.JSX.Element | null {
|
||||
function MessageTime({ time }: { time: number | undefined }): React.JSX.Element | null {
|
||||
const formatted = formatMessageTime(time)
|
||||
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>
|
||||
</span>}
|
||||
<MessageTime time={message.createdAt} />
|
||||
<MessageTime time={message.turnFinishedAt ?? (message.turnStartedAt !== undefined && message.turnDurationMs !== undefined ? message.turnStartedAt + message.turnDurationMs : undefined)} />
|
||||
</span>
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import MessageBubble, { planMarkdown } from './MessageBubble'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
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 SourceChatHeader from './SourceChatHeader'
|
||||
import { useConversationSearch } from '../workbuddy/conversation/useConversationSearch'
|
||||
@@ -97,6 +101,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const messages = useChatStore((s) => s.messages)
|
||||
const running = useChatStore((s) => s.running)
|
||||
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 sessions = useChatStore((s) => s.sessions)
|
||||
const currentId = useChatStore((s) => s.currentSessionId)
|
||||
@@ -108,13 +114,11 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const respond = useChatStore((s) => s.respond)
|
||||
const revert = useChatStore((s) => s.revert)
|
||||
const feedback = useChatStore((s) => s.feedback)
|
||||
const config = useSettingsStore((s) => s.config)
|
||||
const models = useSettingsStore((s) => s.models)
|
||||
const selectedModelId = useSettingsStore((s) => s.selectedModelId)
|
||||
const workspaces = useSettingsStore((s) => s.workspaces)
|
||||
const selectedWorkspaceId = useSettingsStore((s) => s.selectedWorkspaceId)
|
||||
const defaultWorkspaceRoot = useSettingsStore((s) => s.workspace)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>(initialPermissionMode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentId) return
|
||||
@@ -207,9 +211,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const effectivePermissionMode = currentId
|
||||
? activeSessionPermission ?? 'default-sandbox'
|
||||
: activeSessionPermission ?? initialPermissionMode()
|
||||
useEffect(() => {
|
||||
setPermissionMode(effectivePermissionMode)
|
||||
}, [effectivePermissionMode, currentId])
|
||||
|
||||
/**
|
||||
* 原版 resolveRevertEntryVisible(ConversationTimelineImpl.hasRevision):
|
||||
* 有 requestId 且不是“历史已全部加载时的首条用户消息”才显示回退入口;
|
||||
@@ -257,14 +259,17 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
}, [currentId, setDraft])
|
||||
|
||||
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 currentSession = sessions.find((session) => session.id === currentId)
|
||||
const workspaceRoot = currentSession?.cwd ?? draftContext.cwd ?? defaultWorkspaceRoot
|
||||
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 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.
|
||||
const effectiveThinkingLevel = currentSession?.thinkingLevel ?? draftContext.thinkingLevel
|
||||
const capabilityCatalog = useCapabilityCatalog(workspaceRoot)
|
||||
@@ -420,7 +425,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
})
|
||||
|
||||
// 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(() => {
|
||||
const el = scrollRef.current
|
||||
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> => {
|
||||
if (sending) return
|
||||
if (loading || loadError) throw new Error('会话尚未就绪,请重试或等待加载完成')
|
||||
if (!effectiveModel) throw new Error('请先配置自定义模型')
|
||||
if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互')
|
||||
followBottom.current = true
|
||||
const submitKey = draftKey
|
||||
@@ -470,7 +477,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
useChatStore.setState(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)
|
||||
}
|
||||
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)
|
||||
} catch (error) {
|
||||
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,
|
||||
skillIds: edited?.skillIds ?? capabilities.skillIds, mcpServerIds: edited?.mcpServerIds ?? capabilities.mcpServerIds,
|
||||
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)
|
||||
}
|
||||
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,
|
||||
...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 preceding = precedingUserMessage(message)
|
||||
@@ -609,12 +616,12 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
|
||||
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
|
||||
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'
|
||||
? <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}
|
||||
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 })}
|
||||
reject={(id) => {
|
||||
const option = activeInteraction.options.find((item) => item.kind.startsWith('reject'))
|
||||
@@ -644,7 +651,9 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
onSelectCapability={selectCapability}
|
||||
onRemoveCapability={removeCapability}
|
||||
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} />}
|
||||
contextUsageSlot={<ContextUsageRing sessionId={currentId} hasMessages={messages.length > 0} running={sending || !!running} />}
|
||||
addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`}
|
||||
@@ -658,7 +667,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const resourcePath = file.path
|
||||
setDrafts(all => { const previous = all[draftKey] ?? { text: '', attachments: [] }; return { ...all, [draftKey]: { ...previous, resources: [...new Set([...(previous.resources ?? []), resourcePath])] } } })
|
||||
} }} 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) })
|
||||
} }} />
|
||||
}}
|
||||
@@ -689,19 +698,10 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }}
|
||||
onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)}
|
||||
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) => {
|
||||
const owner = useChatStore.getState().currentSessionId
|
||||
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)
|
||||
await useChatStore.getState().selectPermissionMode(mode)
|
||||
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ }
|
||||
}}
|
||||
onError={(message) => useChatStore.setState({ error: message })} />}
|
||||
@@ -722,7 +722,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
if (!currentId) return
|
||||
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
|
||||
}}>
|
||||
<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) => (
|
||||
<MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode}
|
||||
quotedSourceIds={quotedSourceIds}
|
||||
@@ -750,7 +753,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
onBuildPlan={async (plan) => {
|
||||
if (running || sending) throw new Error('请等待当前运行结束')
|
||||
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,
|
||||
rollbackPending: rewinding,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChatMessage, MessagePart } from '@shared/types'
|
||||
import { projectQuestionAnswers } from '../workbuddy/conversation/question-projection'
|
||||
import SourceAgentAvatar from './SourceAgentAvatar'
|
||||
import SourceArtifactLink from './SourceArtifactLink'
|
||||
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 {
|
||||
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 => {
|
||||
setActionError(null)
|
||||
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 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)
|
||||
const activeApprovals = new Map(tools.flatMap(tool => {
|
||||
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 =>
|
||||
interaction.kind === 'question' && ['pending', 'responding', 'error'].includes(interaction.status) ? [interaction.toolCallId] : []))
|
||||
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 (['ask_followup_question', 'ask_user_question', 'askfollowupquestion', 'askuserquestion'].includes(name)) return { role: 'inline-question' }
|
||||
return undefined
|
||||
}
|
||||
const renderTool = (id: string): React.ReactNode => {
|
||||
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
|
||||
const nativeResult = tool.result ?? tool.output ?? tool.summary
|
||||
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}
|
||||
turnFinished={message.status === 'done' || message.status === 'cancelled' || message.status === 'error'}
|
||||
turnCancelled={message.status === 'cancelled'} turnDurationMs={message.turnDurationMs}
|
||||
expandFinishedEvents={liveEventsExpanded}
|
||||
classifyToolCall={classifyTool}
|
||||
renderGroup={(group, children) => {
|
||||
if (!group.isRunning) return <Fragment key={group.key}>{children}</Fragment>
|
||||
const groupedTools = group.units.flatMap(unit => {
|
||||
const part = unit.part as MessagePart
|
||||
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>
|
||||
|
||||
const latestProgressIndex = parts.reduce((latest, part, index) => part.type === 'tool' || part.type === 'reasoning' ? index : latest, -1)
|
||||
if (!group.units.some(unit => unit.index === latestProgressIndex)) return null
|
||||
|
||||
const latestProgressIndex = group.units.at(-1)!.index
|
||||
const latestPart = parts[latestProgressIndex]
|
||||
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 summaryArgs = latestTool && ['list_dir', 'list_files'].includes(latestTool.name) && !toolArgs.target_directory && !toolArgs.root && typeof toolArgs.path === 'string'
|
||||
? { ...toolArgs, target_directory: toolArgs.path }
|
||||
: toolArgs
|
||||
const pendingConfirmation = !!latestTool && activeApprovals.has(latestTool.id)
|
||||
const summaryStatus = !latestTool ? 'success' : pendingConfirmation ? 'pending'
|
||||
: latestTool.status === 'ok' ? 'executed'
|
||||
: ['running', 'executing'].includes(latestTool.status) ? 'stream_executing'
|
||||
: latestTool.status
|
||||
const actionSummary = latestTool ? computeSourceFoldSummary([{ type: 'tool-call', tool: {
|
||||
name: latestTool.name, args: summaryArgs, result: latestTool.result ?? latestTool.output,
|
||||
pendingConfirmation, status: summaryStatus
|
||||
} }], { isRunning: true,
|
||||
t: (key, params) => sourceConversationLabel(key as SourceConversationLabelKey, params) }) : undefined
|
||||
const summaryParts = group.units.map(unit => {
|
||||
if (unit.part.type !== 'tool') return { type: unit.part.type }
|
||||
const toolId = unit.part.toolId
|
||||
const tool = tools.find(item => item.id === toolId)
|
||||
const args = tool?.args && typeof tool.args === 'object' ? tool.args as Record<string, unknown> : {}
|
||||
const summaryArgs = tool && ['list_dir', 'list_files'].includes(tool.name) && !args.target_directory && !args.root && typeof args.path === 'string'
|
||||
? { ...args, target_directory: args.path } : args
|
||||
return { type: 'tool-call', tool: tool ? { name: tool.name, args: summaryArgs,
|
||||
result: tool.result ?? tool.output, pendingConfirmation: activeApprovals.has(tool.id),
|
||||
status: tool.status === 'ok' ? 'executed' : tool.status === 'running' ? 'stream_executing' : tool.status } : undefined }
|
||||
})
|
||||
const actionSummary = computeSourceFoldSummary(summaryParts, { isRunning: group.isRunning, adjacentText: group.adjacentText,
|
||||
t: (key, params) => sourceConversationLabel(key as SourceConversationLabelKey, params) })
|
||||
const actionText = actionSummary?.text || conversationLabel('message.loading.thinking')
|
||||
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}>
|
||||
<SourceToolHeader icon={activityIcon} showIconWhileExecuting
|
||||
isExecuting={actionSummary?.status === 'running'} primaryLoading={actionSummary?.status === 'running'}
|
||||
primaryContent={actionText} />
|
||||
</div>
|
||||
return <LiveEventDisclosure id={`live-events-${encodeURIComponent(message.id)}`}
|
||||
header={activityHeader} expanded={liveEventsExpanded} onExpandedChange={setLiveEventsExpanded}>
|
||||
{liveEventParts.map(({ part, index: eventIndex }) => part.type === 'tool'
|
||||
? <div className="cr-live-event-list__item" data-event-type="tool" key={`tool:${part.toolId}`}>
|
||||
{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>)}
|
||||
return <LiveEventDisclosure id={`events-${encodeURIComponent(message.id)}-${group.startIndex}`}
|
||||
header={activityHeader} expanded={expandedGroups[group.key] ?? false}
|
||||
onExpandedChange={expanded => setExpandedGroups(previous => ({ ...previous, [group.key]: expanded }))}>
|
||||
{children}
|
||||
</LiveEventDisclosure>
|
||||
}}
|
||||
renderUnit={(candidate, index) => {
|
||||
@@ -222,11 +204,11 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
|
||||
)
|
||||
}} />
|
||||
|
||||
{message.interactions?.map((interaction) => interaction.kind === 'question' && interaction.answer &&
|
||||
!message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer)
|
||||
? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
|
||||
{questionProjection.unpositioned.length > 0 && <section className="question-history-unpositioned" aria-label="历史问答(原始位置不可用)">
|
||||
{questionProjection.unpositioned.map(item => <QuestionAnswerTranscript key={item.id} questionAnswer={item.answer} />)}
|
||||
</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 ? (
|
||||
<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>
|
||||
}
|
||||
@@ -27,8 +27,7 @@ import { AccountMenu } from '../workbuddy/user-menu/AccountMenu'
|
||||
import { UpdateSidebarCard } from './updates/UpdateSidebarCard'
|
||||
import ArchivedTasksDialog from './ArchivedTasksDialog'
|
||||
import { TaskContextMenu } from './task-menu'
|
||||
import { TaskConfirmDialog } from './task-confirm-dialog'
|
||||
import type { TaskConfirmKind } from './task-confirm-dialog'
|
||||
import { TaskPopconfirm } from './TaskPopconfirm'
|
||||
import { IconTaskArchive, IconTaskMore as TaskMoreIcon, IconTaskPinBadge as TaskPinIcon } from './task-menu-icons'
|
||||
import type { TaskMenuAction, TaskMenuAnchor } from './task-menu'
|
||||
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 [spaceMenu, setSpaceMenu] = useState<{ space: WorkspaceRecord; top: number; left: number; anchorTop: number } | 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 [renaming, setRenaming] = useState<{ sessionId: string; value: string } | null>(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 === '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 }
|
||||
const anchor = taskRows.current.get(session.id)
|
||||
if (!anchor?.isConnected) return
|
||||
if (action === 'archive') {
|
||||
const running = isSessionRunning(session.id)
|
||||
if (running) { setHint('任务进行中,无法归档'); return }
|
||||
setTaskConfirm({ session, kind: 'archive', running: false })
|
||||
setTaskConfirm({ session, kind: 'archive', running: false, anchor })
|
||||
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 => {
|
||||
@@ -283,7 +285,7 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
|
||||
const pinned = session.pinnedAt !== undefined
|
||||
const status = Object.values(runs).some(run => run.sessionId === session.id) ? '运行中' : sendingSessions.includes(session.id) ? '发送中' : relTime(session.updatedAt)
|
||||
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"
|
||||
onClick={() => { if (editing) return; onNavigate?.('chat'); void selectSession(session.id) }}
|
||||
onContextMenu={(event) => {
|
||||
@@ -520,10 +522,22 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
|
||||
onSelect={(action) => handleTaskMenu(taskMenu.session, action)}
|
||||
onClose={() => setTaskMenu(null)} /> : null}
|
||||
|
||||
{taskConfirm ? <TaskConfirmDialog
|
||||
{taskConfirm ? <TaskPopconfirm
|
||||
anchor={taskConfirm.anchor}
|
||||
kind={taskConfirm.kind}
|
||||
running={taskConfirm.running}
|
||||
onClose={() => setTaskConfirm(null)}
|
||||
running={isSessionRunning(taskConfirm.session.id)}
|
||||
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 () => {
|
||||
if (taskConfirm.kind === 'archive') await setArchived(taskConfirm.session.id, true)
|
||||
else await removeSession(taskConfirm.session.id)
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface SourceChatHeaderPromptList {
|
||||
}
|
||||
export interface SourceChatHeaderProps {
|
||||
title: string
|
||||
loadingTitle?: boolean
|
||||
rightVisible: boolean
|
||||
onToggleRight(): void
|
||||
/** 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
|
||||
|
||||
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 {
|
||||
const [editing, setEditing] = useState(false), [draftTitle, setDraftTitle] = useState(title)
|
||||
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 === 'Escape') { event.preventDefault(); editActive.current = false; setEditing(false); setDraftTitle(title) }
|
||||
}} /> : <span className="workbuddy-topbar-title-area">
|
||||
<span className="workbuddy-topbar-title" title={title}>{title}</span>
|
||||
<button type="button" className="workbuddy-topbar-edit-button" aria-label="Edit title" disabled={!onTitleChange}
|
||||
<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={loadingTitle || !onTitleChange}
|
||||
onClick={event => { event.stopPropagation(); if (!onTitleChange || saving.current) return; setDraftTitle(title); editActive.current = true; setEditing(true) }}>
|
||||
<SourceHeaderEditIcon className={undefined} style={undefined} aria-hidden="true" />
|
||||
</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); }
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { ActiveChatRun, RewindOptions, SendPayload } from '@shared/ipc'
|
||||
import type { InteractionResponse } from '@shared/conversation'
|
||||
@@ -6,7 +8,7 @@ import { applyAssistantEvent } from '@shared/message-parts'
|
||||
import { lastContextUsage, normalizeContextUsageSnapshot } from '@shared/context-usage'
|
||||
import type { SessionContext } 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 { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer'
|
||||
import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases'
|
||||
@@ -67,14 +69,16 @@ interface ChatState {
|
||||
running: RunningState | null
|
||||
sending: boolean
|
||||
loading: boolean
|
||||
loadError: string | null
|
||||
error: string | null
|
||||
draftContext: SessionContext
|
||||
draftId: number
|
||||
init(): Promise<void>
|
||||
selectSession(id: string): Promise<void>
|
||||
selectSession(id: string, options?: { reload?: boolean }): Promise<void>
|
||||
newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise<SessionMeta>
|
||||
startTask(context?: SessionContext): void
|
||||
selectModel(id: string): Promise<void>
|
||||
selectPermissionMode(mode: PermissionMode): Promise<void>
|
||||
selectExpert(id: string | null): Promise<void>
|
||||
selectAgentMode(mode: AgentMode): Promise<void>
|
||||
selectThinkingLevel(level: ThinkingLevel): Promise<void>
|
||||
@@ -123,6 +127,39 @@ let selectionVersion = 0
|
||||
let expertSelectionVersion = 0
|
||||
let expertWrites: 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 cancelRequestedRuns = new Set<string>()
|
||||
function prepareExpert(id: string): Promise<ExpertActivation> {
|
||||
@@ -143,11 +180,11 @@ function sortSessions(sessions: SessionMeta[]): SessionMeta[] {
|
||||
}
|
||||
|
||||
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
|
||||
if (!owner) { set(state => ({ draftContext: { ...state.draftContext, ...patch } })); return }
|
||||
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
|
||||
set(state => ({ sessions: state.sessions.map(session => session.id === owner ? { ...session, ...patch } : session),
|
||||
...contextUsageState(state.sessionUsage, state.sessionContextWindow, owner,
|
||||
@@ -235,7 +272,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
sessions: [], currentSessionId: null, messages: [], sessionMessages: {}, sessionUsage: {}, sessionContextWindow: {},
|
||||
runs: {}, runSequences: {},
|
||||
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() {
|
||||
try {
|
||||
const sessions = await window.ycode.session.list()
|
||||
@@ -244,7 +281,8 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
else get().startTask()
|
||||
} 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
|
||||
const version = ++selectionVersion
|
||||
void window.ycode.chat.setSessionVisible(id)
|
||||
@@ -257,7 +295,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
const hydration: SessionHydration = { version, events: [], started: [] }
|
||||
sessionHydrations.set(id, hydration)
|
||||
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) }))
|
||||
try {
|
||||
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) {
|
||||
if (version === selectionVersion && get().currentSessionId === id) {
|
||||
set({ loading: false, error: errorText(error) })
|
||||
set({ loading: false, loadError: errorText(error), error: errorText(error) })
|
||||
}
|
||||
} finally {
|
||||
if (sessionHydrations.get(id) === hydration) sessionHydrations.delete(id)
|
||||
@@ -298,12 +336,18 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
++expertSelectionVersion
|
||||
++selectionVersion
|
||||
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 }))
|
||||
},
|
||||
async selectModel(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) {
|
||||
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 } : {}) } }))
|
||||
return
|
||||
}
|
||||
const write = expertWrites.catch(() => {}).then(async () => {
|
||||
const write = Promise.all([expertWrites.catch(() => {}), requestSettingsWrites.catch(() => {})]).then(async () => {
|
||||
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 })
|
||||
set(state => ({ sessions: state.sessions.map(item => item.id === owner ? session : item) }))
|
||||
})
|
||||
expertWrites = write
|
||||
trackRequestSettings(owner, write, ['agentMode'])
|
||||
await write
|
||||
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) {
|
||||
const id = created.id
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -391,7 +436,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages },
|
||||
...contextUsageState(state.sessionUsage, state.sessionContextWindow, created.id, lastContextUsage(seedMessages)),
|
||||
...(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
|
||||
},
|
||||
@@ -422,6 +467,8 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
async removeSession(id) {
|
||||
if (get().currentSessionId === id) void window.ycode.chat.setSessionVisible(null)
|
||||
await window.ycode.session.remove(id)
|
||||
requestSettingsByOwner.delete(id)
|
||||
requestSettingErrors.delete(id)
|
||||
pendingEvents.delete(id)
|
||||
sessionHydrations.delete(id)
|
||||
streamCommitter.clearSession(id)
|
||||
@@ -452,6 +499,17 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
async send(text, options = {}) {
|
||||
const trimmed = (text.trim() ? text : (options.userContent ?? []).flatMap(item => item.type === 'text' ? [item.text] : []).join('')).trim()
|
||||
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 (!trimmed && !options.attachments?.length && !options.quotes?.length && !options.resources?.length && !options.skillIds?.length && !options.mcpServerIds?.length && !options.command) return
|
||||
let sessionId = get().currentSessionId
|
||||
@@ -478,14 +536,18 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
}
|
||||
if (sessionId && currentRun(get().runs, sessionId)) {
|
||||
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
|
||||
}
|
||||
if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试')
|
||||
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 (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
|
||||
}
|
||||
const owner = sessionId
|
||||
@@ -493,8 +555,9 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
set((state) => ({ sendingSessions: [...state.sendingSessions, owner],
|
||||
...(state.currentSessionId === owner ? { sending: true, error: null } : {}) }))
|
||||
try {
|
||||
await requestSettingsWrites
|
||||
const result = await window.ycode.chat.send({ ...options, sessionId: owner, text: trimmed })
|
||||
if (modelWrite) await modelWrite
|
||||
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',
|
||||
content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName,
|
||||
assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { useChatStore } from './chat'
|
||||
import { resolveSelectedModel } from '@shared/model-selection'
|
||||
import type { AppInfo, GatewayConfigView } from '@shared/types'
|
||||
import type { ConfigPatchLike } from '@shared/ipc'
|
||||
import type { CustomModelInput, ModelRegistryView } from '@shared/models'
|
||||
@@ -27,7 +29,7 @@ interface SettingsState {
|
||||
chooseWorkspace(): Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
config: null,
|
||||
workspace: '',
|
||||
appInfo: null,
|
||||
@@ -44,8 +46,22 @@ export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
])
|
||||
set({ config, workspace: workspace.root, appInfo, ...registry, ...spaces, loaded: true })
|
||||
},
|
||||
async saveModel(input, overwrite) { set(await window.ycode.models.save(input, overwrite)) },
|
||||
async deleteModel(id) { set(await window.ycode.models.remove(id)) },
|
||||
async saveModel(input, overwrite) {
|
||||
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 createWorkspace(name) { set(await window.ycode.workspace.generate(name)) },
|
||||
async attachWorkspace(path) {
|
||||
@@ -67,3 +83,16 @@ export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
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)
|
||||
}))
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
/**
|
||||
* YCode UI Popover / PopoverTooltip(P4)。
|
||||
*
|
||||
* 实现逐行迁移自 `components/source-select/SourceSelectPopover.tsx` 的 REA 适配壳,
|
||||
* 基础实现迁移自 `components/source-select/SourceSelectPopover.tsx` 的 REA 适配壳,
|
||||
* 只改文件位置与导出名:DOM class、floating middleware 顺序、inline/portal 策略、
|
||||
* focus/Escape/outside pointer、transitionPreset 与 aria 合并逻辑一律未改动。
|
||||
* 新增显式锚点、箭头及点击吞掉等可选能力;默认保持旧调用行为。
|
||||
* 迁移保留的既有字面量(数值已存在,token 无法立即表达,本次不发明 token、不改行为):
|
||||
* viewportPadding/offset 默认 8、退出动画 150(reduced-motion 1)、zIndex 1100,
|
||||
* 以及按浮层测量结果写入的行内尺寸。
|
||||
*/
|
||||
import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
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'
|
||||
export { RecoveredSelectTooltip as PopoverTooltip } from '../components/source-select/RecoveredSelectTooltip'
|
||||
|
||||
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)
|
||||
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'
|
||||
}
|
||||
// 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)
|
||||
}, [open, transitionPreset])
|
||||
const close = useCallback(() => onOpenChange(false), [onOpenChange])
|
||||
const mergedTrigger = cloneElement(trigger, {
|
||||
const mergedTrigger = trigger ? cloneElement(trigger, {
|
||||
ref: (element: HTMLElement | null) => {
|
||||
anchor.current = element
|
||||
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-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) }
|
||||
})
|
||||
}) : 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>}</>
|
||||
}
|
||||
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 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 { refs, floatingStyles, context, placement: actual, isPositioned } = useFloating({
|
||||
open: true, onOpenChange: next => { if (!next && !exiting) 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` }) } })] : [])],
|
||||
const { refs, floatingStyles, context, placement: actual, isPositioned, middlewareData } = useFloating({
|
||||
open: true, onOpenChange: next => { if (!next && !exiting && dismissible) close() }, placement,
|
||||
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
|
||||
})
|
||||
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(() => {
|
||||
if (exiting) return
|
||||
const outside = (event: PointerEvent): void => {
|
||||
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 => {
|
||||
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.
|
||||
document.addEventListener('keydown', escape, true)
|
||||
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) }
|
||||
}, [anchor, close, exiting, refs.floating])
|
||||
return () => { document.removeEventListener('pointerdown', outside, !!consumeOutsidePointer); document.removeEventListener('keydown', escape, true) }
|
||||
}, [reference, close, exiting, refs.floating, consumeOutsidePointer, returnFocusElement, dismissible])
|
||||
useEffect(() => { if (refs.floating.current) refs.floating.current.inert = exiting }, [exiting, refs.floating])
|
||||
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 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(' ')}
|
||||
style={{ ...floatingStyles, zIndex: 1100, visibility: isPositioned || exiting ? 'visible' : 'hidden', ...(expand ? { transformOrigin: origin } : {}) }}>{children}</div>
|
||||
const managed = manageFocus ? <FloatingFocusManager context={context} modal={false} initialFocus={initialFocus ?? refs.floating}>{element}</FloatingFocusManager> : element
|
||||
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 disabled={!isPositioned || exiting} context={context} modal={false} returnFocus={returnFocusElement ? { current: returnFocusElement } : true} initialFocus={initialFocus ?? refs.floating}>{element}</FloatingFocusManager> : element
|
||||
if (inline) return managed
|
||||
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)。 */
|
||||
contextUsageSlot?: ReactNode
|
||||
permissionSlot?: ReactNode
|
||||
modeSlot?: ReactNode
|
||||
expertSlot?: ReactNode
|
||||
floatingSlot?: ReactNode
|
||||
queueSlot?: ReactNode
|
||||
@@ -82,6 +83,7 @@ export interface ComposerControlsProps {
|
||||
workspaceFooter?: ReactNode
|
||||
permissionPlacement?: 'toolbar' | 'footer'
|
||||
disabled?: boolean
|
||||
sendDisabled?: boolean
|
||||
sending?: boolean
|
||||
onStop?(): void | Promise<void>
|
||||
placeholder?: string
|
||||
@@ -167,6 +169,7 @@ export function ComposerControls({
|
||||
branchLabelSlot,
|
||||
contextUsageSlot,
|
||||
permissionSlot,
|
||||
modeSlot,
|
||||
expertSlot,
|
||||
floatingSlot,
|
||||
queueSlot,
|
||||
@@ -184,6 +187,7 @@ export function ComposerControls({
|
||||
workspaceFooter,
|
||||
permissionPlacement = 'toolbar',
|
||||
disabled = false,
|
||||
sendDisabled = false,
|
||||
sending = false,
|
||||
onStop,
|
||||
placeholder = '发送消息…',
|
||||
@@ -423,7 +427,7 @@ export function ComposerControls({
|
||||
const submit = async (): Promise<void> => {
|
||||
const editorContent = richEditorRef.current?.content() ?? userContent
|
||||
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 &&
|
||||
!skills.length && !selectedCapabilities.length && !hasDraftUserContent({ userContent: editorContent }))) return
|
||||
const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES)
|
||||
@@ -574,8 +578,9 @@ export function ComposerControls({
|
||||
if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor)
|
||||
}
|
||||
}} />
|
||||
{permissionSlot}
|
||||
{branchLabelSlot}
|
||||
{permissionSlot}
|
||||
{modeSlot}
|
||||
{expertSlot}
|
||||
</div>
|
||||
|
||||
@@ -594,7 +599,7 @@ export function ComposerControls({
|
||||
<span className="cr-send-button__icon"><IconStop /></span>
|
||||
</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>
|
||||
</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 {
|
||||
mode: PermissionMode
|
||||
disabled?: boolean
|
||||
disabledReason?: string
|
||||
labels?: Partial<Record<PermissionMode, string>>
|
||||
confirmFullAccessOnChange?: boolean
|
||||
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.
|
||||
* 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 [confirming, setConfirming] = 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 dialog = useRef<HTMLDivElement>(null)
|
||||
const id = useId()
|
||||
useEffect(() => {
|
||||
if (disabled) { setOpen(false); setConfirming(false); setAcknowledged(false) }
|
||||
}, [disabled])
|
||||
const full = mode === 'full-access'
|
||||
const label = labels?.[mode] ?? t(full ? 'chatInput.permission.fullAccess' : 'chatInput.permission.label')
|
||||
useEffect(() => {
|
||||
@@ -79,9 +83,9 @@ export default function SourcePermissionControl({ mode, disabled, labels, confir
|
||||
else { setAcknowledged(false); setConfirming(true) }
|
||||
}
|
||||
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}
|
||||
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(' ')}>
|
||||
<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>
|
||||
@@ -114,7 +118,7 @@ export default function SourcePermissionControl({ mode, disabled, labels, confir
|
||||
</div>
|
||||
</div>
|
||||
</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 => {
|
||||
if (event.target === event.currentTarget && !pending) { setConfirming(false); setAcknowledged(false) }
|
||||
}}>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
classifyCompletedFoldPart,
|
||||
type CompletedFoldOptions,
|
||||
type CompletedFoldPart,
|
||||
type CompletedFoldRenderUnit,
|
||||
type CompletedFoldSegment,
|
||||
type CompletedFoldSummaryGroup,
|
||||
type CompletedFoldUnit
|
||||
@@ -159,6 +158,7 @@ export function CompletedFold({
|
||||
renderUnit: render,
|
||||
renderGroup
|
||||
}: CompletedFoldProps) {
|
||||
const [completedExpanded, setCompletedExpanded] = useState(expandFinishedEvents)
|
||||
const reactId = useId()
|
||||
const foldKey = encodeURIComponent(foldId ?? reactId)
|
||||
|
||||
@@ -180,6 +180,19 @@ export function CompletedFold({
|
||||
})}</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) => {
|
||||
if (segment.kind === 'unit') return renderPresentationUnit(segment.unit, render, renderGroup)
|
||||
if (segment.kind === 'completed') {
|
||||
@@ -189,7 +202,8 @@ export function CompletedFold({
|
||||
id={`completed-${foldKey}`}
|
||||
variant="completed"
|
||||
title={model.title}
|
||||
defaultExpanded={expandFinishedEvents && segment.units.some(renderUnitContainsEvent)}
|
||||
controlledExpanded={completedExpanded}
|
||||
onExpandedChange={setCompletedExpanded}
|
||||
>
|
||||
{segment.units.map(unit => renderPresentationUnit(unit, render, renderGroup))}
|
||||
</CollapseSection>
|
||||
@@ -210,10 +224,3 @@ export function CompletedFold({
|
||||
|
||||
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 type { PermissionLabels, PermissionOption } from './types'
|
||||
|
||||
@@ -40,6 +42,7 @@ export interface PermissionConfirmProps {
|
||||
pendingId: string
|
||||
toolName: string
|
||||
toolKind?: string
|
||||
details?: ApprovalDetails
|
||||
approvalScope?: 'once' | 'session'
|
||||
options: PermissionOption[]
|
||||
resolve: (pendingId: string, result: { optionId: string }) => void | Promise<void>
|
||||
@@ -57,6 +60,7 @@ export function PermissionConfirm({
|
||||
pendingId,
|
||||
toolName,
|
||||
toolKind,
|
||||
details,
|
||||
approvalScope,
|
||||
options,
|
||||
resolve,
|
||||
@@ -74,11 +78,25 @@ export function PermissionConfirm({
|
||||
const [localStatus, setLocalStatus] = useState<PermissionInteractionStatus>('pending')
|
||||
const [localError, setLocalError] = useState<string>()
|
||||
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(() => {
|
||||
mounted.current = true
|
||||
setLocalStatus('pending')
|
||||
setLocalError(undefined)
|
||||
setBusy(false)
|
||||
busyRef.current = false
|
||||
setShowRule(false)
|
||||
if (details?.defaultToNo) rejectRef.current?.focus(); else firstRef.current?.focus()
|
||||
return () => { mounted.current = false }
|
||||
}, [pendingId])
|
||||
|
||||
const status = externalStatus !== 'pending' ? externalStatus : localStatus
|
||||
@@ -88,36 +106,42 @@ export function PermissionConfirm({
|
||||
const isResponding = busy || status === 'responding'
|
||||
|
||||
const choose = async (option: PermissionOption | undefined) => {
|
||||
if (!option || busy || !isOpen) return
|
||||
if (!option || busyRef.current || !isOpen) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setLocalError(undefined)
|
||||
setLocalStatus('responding')
|
||||
try {
|
||||
await resolve(pendingId, { optionId: option.optionId })
|
||||
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
|
||||
setLocalStatus('answered')
|
||||
onDecisionComplete?.('answered')
|
||||
} catch (caught) {
|
||||
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
|
||||
setLocalError(caught instanceof Error ? caught.message : String(caught))
|
||||
setLocalStatus('error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (mounted.current && currentId.current === pendingId) { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
}
|
||||
|
||||
const decline = async () => {
|
||||
if (busy || !isOpen) return
|
||||
if (busyRef.current || !isOpen) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setLocalError(undefined)
|
||||
setLocalStatus('responding')
|
||||
try {
|
||||
await reject(pendingId)
|
||||
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
|
||||
setLocalStatus('rejected')
|
||||
onDecisionComplete?.('rejected')
|
||||
} catch (caught) {
|
||||
if (!mounted.current || currentId.current !== pendingId || ['cancelled', 'rejected'].includes(externalStatusRef.current)) return
|
||||
setLocalError(caught instanceof Error ? caught.message : String(caught))
|
||||
setLocalStatus('error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (mounted.current && currentId.current === pendingId) { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,43 +153,60 @@ export function PermissionConfirm({
|
||||
|
||||
return (
|
||||
<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"
|
||||
aria-label={heading}
|
||||
aria-busy={isResponding}
|
||||
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">
|
||||
<div className="conversation-high-credit-approval__title-row"><span className="conversation-high-credit-approval__title">{heading}</span></div>
|
||||
{description && <p className="conversation-high-credit-approval__description">{description}</p>}
|
||||
<header className="conversation-intercept-card__header">
|
||||
<div className="conversation-intercept-card__title-row"><span className="conversation-intercept-card__title">{heading}</span>
|
||||
<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>
|
||||
{status === 'pending' || status === 'responding' || status === 'error' ? (
|
||||
<>
|
||||
{status === 'error' && <div className="conversation-high-credit-approval__error" role="alert">{labels.failed}{error ? `: ${error}` : ''}</div>}
|
||||
<div className="conversation-high-credit-approval__content">
|
||||
{status === 'error' && <div className="conversation-intercept-card__error" role="alert">{labels.failed}{error ? `: ${error}` : ''}</div>}
|
||||
<div className="conversation-intercept-card__content">
|
||||
{actionOptions.map((action, index) => (
|
||||
<button
|
||||
key={`${action.kind}-${action.option?.optionId || 'reject'}`}
|
||||
type="button"
|
||||
ref={action.kind === 'reject' ? rejectRef : index === 0 ? firstRef : undefined}
|
||||
className={[
|
||||
'conversation-high-credit-approval__option',
|
||||
action.kind === 'reject' && 'conversation-high-credit-approval__option--reject'
|
||||
'conversation-intercept-card__option',
|
||||
action.kind === 'reject' && 'conversation-intercept-card__option--reject'
|
||||
].filter(Boolean).join(' ')}
|
||||
disabled={isResponding || (action.kind !== 'reject' && !action.option)}
|
||||
aria-label={action.label}
|
||||
data-option-id={action.option?.optionId}
|
||||
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-high-credit-approval__option-text">{action.label}</span>
|
||||
{action.option && <ApprovalChevronIcon className="conversation-high-credit-approval__option-arrow" />}
|
||||
<span className="conversation-intercept-card__option-num" aria-hidden="true">{index + 1}</span>
|
||||
<span className="conversation-intercept-card__option-text">{action.label}</span>
|
||||
{action.option && <ApprovalChevronIcon className="conversation-intercept-card__option-arrow" />}
|
||||
</button>
|
||||
))}
|
||||
</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 === 'rejected' ? labels.rejected
|
||||
: status === 'cancelled' ? labels.cancelled
|
||||
|
||||
@@ -53,8 +53,8 @@ export interface QuestionFloatingHandle {
|
||||
export interface QuestionFloatingProps {
|
||||
config: QuestionFloatingConfig
|
||||
onSelect?: (answerText: string, answer: QuestionAnswerPayload) => void | Promise<void>
|
||||
onDismiss?: () => void
|
||||
onSkip?: () => void
|
||||
onDismiss?: () => void | Promise<void>
|
||||
onSkip?: () => void | Promise<void>
|
||||
className?: string
|
||||
confirmVariant?: boolean
|
||||
titleHintIcon?: ReactNode
|
||||
@@ -68,11 +68,11 @@ type CustomAnswerMap = Record<string, string>
|
||||
type CustomToggleMap = Record<string, boolean>
|
||||
|
||||
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 {
|
||||
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 {
|
||||
@@ -126,7 +126,7 @@ function isValidQuestion(question: ConversationQuestion): boolean {
|
||||
function isValidOption(option: QuestionOption): boolean {
|
||||
if (typeof option === 'string') return true
|
||||
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 {
|
||||
@@ -153,16 +153,25 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
) {
|
||||
const labels = { ...defaultQuestionLabels, ...labelOverrides }
|
||||
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 [selected, setSelected] = useState<AnswerMap>(() => getDefaultQuestionSelections(config.questions || []))
|
||||
const [selected, setSelected] = useState<AnswerMap>(() => getDefaultQuestionSelections(questions))
|
||||
const [custom, setCustom] = useState<CustomAnswerMap>({})
|
||||
const [includeCustom, setIncludeCustom] = useState<CustomToggleMap>({})
|
||||
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 customInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
generation.current += 1
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current)
|
||||
submitting.current = false
|
||||
setLocalError(null)
|
||||
setIndex(0)
|
||||
setCustom({})
|
||||
setIncludeCustom({})
|
||||
@@ -171,6 +180,26 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
setSelected(getDefaultQuestionSelections(questions))
|
||||
}, [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 current = questions[currentIndex]
|
||||
const isLast = currentIndex === questions.length - 1
|
||||
@@ -191,8 +220,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
if (submitted || responding) return
|
||||
const payload = getPayload(nextSelected, nextCustom, nextIncludeCustom)
|
||||
if (!payload) return
|
||||
setSubmitted(true)
|
||||
void Promise.resolve(onSelect?.(answerText(payload, labels), payload)).catch(() => setSubmitted(false))
|
||||
commit(() => onSelect?.(answerText(payload, labels), payload))
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -223,7 +251,8 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
if (isLast) {
|
||||
submit(nextSelected, nextCustom)
|
||||
} 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 } : {})
|
||||
}
|
||||
setSubmitted(true)
|
||||
void Promise.resolve(onSelect?.(answerText(payload, labels), payload)).catch(() => setSubmitted(false))
|
||||
commit(() => onSelect?.(answerText(payload, labels), payload))
|
||||
} else {
|
||||
setIndex(value => Math.min(value + 1, questions.length - 1))
|
||||
}
|
||||
@@ -264,8 +292,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
|
||||
const dismiss = () => {
|
||||
if (responding || submitted) return
|
||||
if (onSkip) onSkip()
|
||||
else onDismiss?.()
|
||||
commit(() => onSkip ? onSkip() : onDismiss?.())
|
||||
}
|
||||
|
||||
const showSingleSkip = !current.multiSelect && questions.length !== 1 && !isLast && !hasCustomText
|
||||
@@ -274,6 +301,10 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={rootRef}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() }
|
||||
}}
|
||||
className={[
|
||||
'question-floating',
|
||||
confirmVariant && 'question-floating--confirm',
|
||||
@@ -433,7 +464,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
onFocus={() => setCustomFocused(true)}
|
||||
onBlur={() => setCustomFocused(false)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && !current.multiSelect && hasCustomText) {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing && !current.multiSelect && hasCustomText) {
|
||||
event.preventDefault()
|
||||
advance()
|
||||
}
|
||||
@@ -442,7 +473,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
|
||||
</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={[
|
||||
'question-floating__footer',
|
||||
!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 { SOURCE_LOADING_TIPS } from './streaming-loading-tips'
|
||||
import { conversationLabel } from './labels'
|
||||
@@ -100,27 +101,33 @@ export interface StreamingFooterProps {
|
||||
locale?: string
|
||||
/** 新任务启动阶段文案(原版 chat.prepare.<stage>);提供时替换「思考中」。 */
|
||||
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 brandName = activeBrandName()
|
||||
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
|
||||
// 设置 → 个性化「欢迎语」:关闭后不再轮播加载提示(服务端 token 用量与思考中状态保持不变)。
|
||||
const tipsEnabled = useSyncExternalStore(subscribeLoadingTips, loadingTipsEnabled, () => true)
|
||||
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(() => {
|
||||
if (!tipsEnabled) { setTip(null); return }
|
||||
if (!showTips) { setTip(null); return }
|
||||
return scheduleLoadingTips({
|
||||
tips,
|
||||
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">
|
||||
<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>
|
||||
{tipsEnabled && tip && <span
|
||||
{showTips && tip && <span
|
||||
className="_tipPhrase_13r4w_108 _trailingTipPhrase_13r4w_179 cb-loading-tip cb-loading-tip--trailing"
|
||||
onMouseEnter={() => { isPaused.current = true }}
|
||||
onMouseLeave={() => { isPaused.current = false }}
|
||||
tabIndex={0}
|
||||
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 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>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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 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'),
|
||||
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')
|
||||
|
||||
@@ -258,7 +258,7 @@ function buildSourceSegments(
|
||||
const segments: BuildSegment[] = []
|
||||
let foldable: CompletedFoldUnit[] = []
|
||||
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 })
|
||||
foldable = []
|
||||
}
|
||||
@@ -305,8 +305,8 @@ function buildSourceSegments(
|
||||
const segment = segments[index]
|
||||
if (segment.kind === 'foldable') {
|
||||
if (segment.summaryGroup) {
|
||||
const containsToolCall = segment.units.some(unit => unit.part.type === 'tool')
|
||||
segment.summaryGroup.shouldFold = bodyTextAfter || turnFinished || containsToolCall
|
||||
segment.summaryGroup.shouldFold = bodyTextAfter || turnFinished
|
||||
segment.summaryGroup.isRunning = !turnFinished && !bodyTextAfter
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -338,3 +338,11 @@
|
||||
.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: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)
|
||||
*/
|
||||
._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
|
||||
question?: string
|
||||
option?: string
|
||||
label?: string
|
||||
description?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
@@ -58,6 +58,19 @@ export interface QuestionAnswer {
|
||||
questions: Array<{ id: string; question: string; answers: 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'
|
||||
interface InteractionBase {
|
||||
id: string
|
||||
@@ -69,7 +82,7 @@ interface InteractionBase {
|
||||
}
|
||||
export type ChatInteraction =
|
||||
| (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 =
|
||||
| { kind: 'question'; answer: QuestionAnswer; skipped?: boolean }
|
||||
| { kind: 'approval'; optionId: string }
|
||||
|
||||
@@ -13,6 +13,11 @@ export function getMessageParts(message: ChatMessage): MessagePart[] {
|
||||
/** 主进程持久化和渲染进程使用同一事件归约,避免重载前后内容顺序不同。 */
|
||||
export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): ChatMessage {
|
||||
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': {
|
||||
const contextUsage = normalizeContextUsageSnapshot({ usage: event.usage, contextWindow: event.contextWindow,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
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 'error': {
|
||||
@@ -91,18 +97,19 @@ export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): Cha
|
||||
const tools = message.tools?.map((tool): ToolCallRecord =>
|
||||
tool.status === 'running' ? { ...tool, status } : tool
|
||||
)
|
||||
const interactions = message.interactions?.map((item) => item.status === 'pending' || item.status === 'responding' ? { ...item, status: 'cancelled' as const } : item)
|
||||
return { ...message, tools, interactions, parts: message.parts, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) }
|
||||
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, runtimeProgress: undefined, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) }
|
||||
}
|
||||
default:
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
function terminalDuration(event: ChatEvent): Pick<ChatMessage, 'turnDurationMs' | 'turnStartedAt'> {
|
||||
function terminalDuration(event: ChatEvent): Pick<ChatMessage, 'turnDurationMs' | 'turnStartedAt' | 'turnFinishedAt'> {
|
||||
return {
|
||||
...(typeof event.turnDurationMs === 'number' && Number.isFinite(event.turnDurationMs) && event.turnDurationMs >= 0
|
||||
? { turnDurationMs: event.turnDurationMs } : {}),
|
||||
...(typeof event.turnFinishedAt === 'number' && Number.isFinite(event.turnFinishedAt) ? { turnFinishedAt: event.turnFinishedAt } : {}),
|
||||
turnStartedAt: undefined
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -34,6 +34,8 @@ export interface ChatMessage {
|
||||
/** Independent local counts of one prepared provider input; never a split of billed usage. */
|
||||
contextBreakdown?: ContextUsageBreakdown
|
||||
interactions?: ChatInteraction[]
|
||||
/** Live, observed runtime state; cleared on every terminal event. */
|
||||
runtimeProgress?: RuntimeProgress
|
||||
requestId?: string
|
||||
/** Last top-level Claude chain entry for this assistant turn, used only as a fork point. */
|
||||
claudeEntryId?: string
|
||||
@@ -45,6 +47,8 @@ export interface ChatMessage {
|
||||
turnStartedAt?: number
|
||||
/** Measured user-send to terminal-event duration, used by the source completed fold. */
|
||||
turnDurationMs?: number
|
||||
/** Actual observed terminal timestamp, absent for legacy history. */
|
||||
turnFinishedAt?: number
|
||||
/** 本回合的终态 */
|
||||
status?: 'streaming' | 'done' | 'error' | 'cancelled'
|
||||
error?: string
|
||||
@@ -215,9 +219,18 @@ export interface MemorySettingsView extends 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: 'progress'; progress: RuntimeProgress | null }
|
||||
| { runId: string; type: 'text'; delta: string }
|
||||
| { runId: string; type: 'part'; part: Exclude<MessagePart, { type: 'text' } | { type: 'tool' }> }
|
||||
| { runId: string; type: 'interaction'; interaction: ChatInteraction }
|
||||
|
||||
Reference in new issue
Block a user