1手机/浏览器https://设备名.网络名.ts.net
→
diff --git a/tests/chat-list-toolbar.test.cjs b/tests/chat-list-toolbar.test.cjs
new file mode 100644
index 00000000..0ff2f459
--- /dev/null
+++ b/tests/chat-list-toolbar.test.cjs
@@ -0,0 +1,16 @@
+'use strict';
+const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
+
+test('list filters follow the selected label and language',()=>{
+ const events={},documentEvents={};
+ const measure={style:{},textContent:'',getBoundingClientRect(){return {width:this.textContent.length*8};}};
+ const select=text=>({style:{},selectedOptions:[{textContent:text}],getBoundingClientRect(){return {width:parseInt(this.style.width,10)}}});
+ const surface=select('All'),mode=select('Recent');
+ const toolbar={querySelectorAll:()=>[surface,mode],addEventListener(name,fn){events[name]=fn;}};
+ const context=vm.createContext({document:{createElement:()=>measure,body:{append(){}},addEventListener(name,fn){documentEvents[name]=fn;}},window:{addEventListener(){}},getComputedStyle:()=>({font:'12px sans-serif',letterSpacing:'normal'}),requestAnimationFrame:fn=>fn()});
+ vm.runInContext(fs.readFileSync('web/features/clients/client-navigation.js','utf8'),context);
+ vm.runInContext('bindChatListToolbar(toolbar)',Object.assign(context,{toolbar}));
+ assert.equal(mode.style.width,'78px');assert.equal(surface.style.width,'54px');
+ mode.selectedOptions[0].textContent='Project';events.change();assert.equal(mode.style.width,'86px');
+ mode.selectedOptions[0].textContent='按项目';documentEvents['bridge-language']();assert.equal(mode.style.width,'54px');
+});
diff --git a/tests/claude-connector-lifecycle.test.cjs b/tests/claude-connector-lifecycle.test.cjs
new file mode 100644
index 00000000..afa85824
--- /dev/null
+++ b/tests/claude-connector-lifecycle.test.cjs
@@ -0,0 +1,114 @@
+'use strict';
+const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
+const {webcrypto,createHmac}=require('node:crypto');
+const template=fs.readFileSync('bridge/clients/claude/connector.js','utf8');
+const config=(cwd,generation)=>({cwd,request:'request.json',response:'response.json',token:generation,generation,reconnect:true,connectorRevision:5,inspectionMarker:generation});
+const packet=(cfg,value)=>{const payload=JSON.stringify({generation:cfg.generation,...value});return JSON.stringify({payload,signature:createHmac('sha256',cfg.token).update(payload).digest('hex')});};
+const waitFor=async predicate=>{const until=Date.now()+3000;while(!predicate()){if(Date.now()>until)throw Error('timeout');await new Promise(resolve=>setTimeout(resolve,10));}};
+function runtime(configs,{fastTimeouts=false}={}){
+ const requests=new Map(configs.map(cfg=>[cfg.cwd,packet(cfg,{type:'idle',seq:0})]));
+ const writes=[],calls=[];let delayRead=false,readHeld=false,releaseRead,delayWrite=false,releaseWrite,delaySign=false,signHeld=false,releaseSign;
+ const original=function(models){return models;};
+ const api={setAvailableCodeModels:original,
+ async getAll(){return configs.map((cfg,i)=>({sessionId:'session-'+i,cwd:cfg.cwd,title:'Codex Bridge Connector'}));},
+ async readFileAtCwd(cwd){if(delayRead){delayRead=false;readHeld=true;return new Promise(resolve=>{releaseRead=contents=>resolve({contents:contents||requests.get(cwd)});});}return {contents:requests.get(cwd)};},
+ async writeSessionFile(id,path,text){if(delayWrite)return new Promise(resolve=>{releaseWrite=()=>{writes.push(JSON.parse(JSON.parse(text).payload));resolve({hash:'ok'});};});writes.push(JSON.parse(JSON.parse(text).payload));return {hash:'ok'};},
+ async sendMessage(...args){calls.push(args);}
+ };
+ const window={'claude.web':{LocalSessions:api}};
+ const crypto={subtle:{importKey:(...args)=>webcrypto.subtle.importKey(...args),verify:(...args)=>webcrypto.subtle.verify(...args),
+ sign:(...args)=>delaySign?(delaySign=false,signHeld=true,new Promise(resolve=>{releaseSign=()=>webcrypto.subtle.sign(...args).then(resolve);})):webcrypto.subtle.sign(...args)}};
+ const context={window,crypto,TextEncoder,Uint8Array,Date,
+ setTimeout:(fn,ms)=>setTimeout(fn,fastTimeouts&&ms===4000?30:ms),clearTimeout,console:{info(){},error(){}}};
+ const inject=cfg=>vm.runInNewContext(template.replace('__BRIDGE_CONFIG__',JSON.stringify(cfg)),context);
+ return {window,api,writes,calls,requests,original,inject,delayNextRead(){delayRead=true;},readPending(){return readHeld;},releaseRead(contents){releaseRead(contents);},delayNextSign(){delaySign=true;},signPending(){return signHeld;},releaseSign(){releaseSign();},delayNextWrite(){delayWrite=true;},writePending(){return !!releaseWrite;},releaseWrite(){delayWrite=false;releaseWrite();}};
+}
+test('signed stop detaches one gateway and permits a different workspace to connect',async()=>{
+ const first=config('/gateway-a','first'),second=config('/gateway-b','second'),ui=runtime([first,second]);
+ const oldRunning=ui.inject(first);await waitFor(()=>ui.writes.some(row=>row.generation==='first'&&row.connected));
+ const old=ui.window.__claudeMobileBridge,oldObserver=ui.api.setAvailableCodeModels;
+ assert.notEqual(oldObserver,ui.original);
+ ui.requests.set(first.cwd,packet(first,{type:'stop',seq:1}));
+ await waitFor(()=>old.status().suspended&&!old.status().writing);
+ assert.equal(ui.writes.at(-1).connected,false);
+ const newRunning=ui.inject(second);await waitFor(()=>ui.window.__claudeMobileBridge?.owns(second.cwd));
+ assert.equal(old.status().stopped,true);assert.notEqual(ui.api.setAvailableCodeModels,oldObserver);
+ ui.window.__claudeMobileBridge.stop();await Promise.all([oldRunning,newRunning]);
+ assert.equal(ui.api.setAvailableCodeModels,ui.original,'observer restores Desktop original after transfer');
+});
+test('foreign conflict leaves the existing model observer untouched',async()=>{
+ const first=config('/gateway-a','first'),second=config('/gateway-b','second'),ui=runtime([first,second]);
+ const oldRunning=ui.inject(first);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,observer=ui.api.setAvailableCodeModels;
+ await assert.rejects(ui.inject(second),/其他网关占用/);
+ assert.equal(ui.window.__claudeMobileBridge,old);assert.equal(ui.api.setAvailableCodeModels,observer);
+ old.stop();await oldRunning;assert.equal(ui.api.setAvailableCodeModels,ui.original);
+});
+test('replacement while old read is pending cannot dispatch its late request',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const oldRunning=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge;
+ ui.delayNextRead();await waitFor(()=>ui.readPending());
+ const newRunning=ui.inject(cfg);await waitFor(()=>ui.window.__claudeMobileBridge!==old);
+ ui.releaseRead(packet(cfg,{type:'request',seq:1,surface:'code',method:'sendMessage',args:['session-0','late'],expires:Date.now()/1000+10}));await oldRunning;await new Promise(resolve=>setTimeout(resolve,30));
+ assert.equal(ui.calls.length,0);
+ ui.window.__claudeMobileBridge.stop();await newRunning;
+});
+test('a response write in progress blocks replacement and direct stop prevents a later write',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const oldRunning=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,baseline=ui.writes.length;
+ ui.delayNextWrite();ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:0}));
+ await waitFor(()=>old.status().writing&&ui.writePending());
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ assert.equal(ui.window.__claudeMobileBridge,old);
+ old.stop();ui.releaseWrite();await oldRunning;
+ assert.equal(ui.writes.length,baseline+1,'an already submitted write completes, with no follow-up publish');
+});
+test('response signing is covered by the write guard and direct stop drops its result',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const oldRunning=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,baseline=ui.writes.length;
+ ui.delayNextSign();ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:0}));
+ await waitFor(()=>ui.signPending()&&old.status().writing);
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ old.stop();ui.releaseSign();await oldRunning;
+ assert.equal(ui.writes.length,baseline,'stopped connector must not submit a response after signing');
+});
+test('CDP invocation advertises pending work and blocks same-owner replacement',async()=>{
+ const cfg={...config('/same','same'),transport:'cdp'},ui=runtime([cfg]);
+ await ui.inject(cfg);const old=ui.window.__claudeMobileBridge;
+ const finishes=[];ui.api.getAll=()=>new Promise(resolve=>{finishes.push(()=>resolve([]));});
+ const message=seq=>({token:cfg.token,generation:cfg.generation,seq,expires:Date.now()/1000+10,surface:'code',method:'getAll',args:[]});
+ const first=old.invoke(message(1)),second=old.invoke(message(2));
+ await waitFor(()=>old.status().busy);
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ assert.equal(ui.window.__claudeMobileBridge,old);
+ await waitFor(()=>finishes.length===2);finishes[0]();await first;
+ assert.equal(old.status().busy,true,'one completed invoke cannot clear the other');
+ finishes[1]();await second;assert.equal(old.status().busy,false);old.stop();
+});
+test('file operation remains owned after its display deadline until Desktop settles',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const running=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge;let finish;
+ ui.api.getAll=()=>new Promise(resolve=>{finish=()=>resolve([]);});
+ ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:Date.now()/1000+.8}));
+ await waitFor(()=>ui.writes.some(row=>row.seq===1&&row.done&&/超时/.test(row.error)));
+ assert.equal(old.status().busy,true,'timeout must not hide an unsettled native call');
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ finish();await waitFor(()=>old.status().busy===false);
+ old.stop();await running;
+});
+test('overlapping timed-out publishes keep the write guard until both settle',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg],{fastTimeouts:true});
+ const running=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,finishes=[];
+ ui.api.writeSessionFile=()=>new Promise(resolve=>{finishes.push(()=>resolve({hash:'ok'}));});
+ ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:0}));
+ await waitFor(()=>finishes.length===2);
+ finishes[0]();await new Promise(resolve=>setTimeout(resolve,10));
+ assert.equal(old.status().writing,true,'first completion cannot clear a later pending write');
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ old.stop();finishes[1]();await running;
+});
diff --git a/tests/claude-connector-owner.test.cjs b/tests/claude-connector-owner.test.cjs
new file mode 100644
index 00000000..cfc885d7
--- /dev/null
+++ b/tests/claude-connector-owner.test.cjs
@@ -0,0 +1,52 @@
+'use strict';
+const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
+const {webcrypto,createHmac}=require('node:crypto');
+const config={cwd:'/gateway-b',request:'request.json',response:'response.json',token:'fixture',generation:'generation-b',reconnect:true,connectorRevision:5};
+const source=fs.readFileSync('bridge/clients/claude/connector.js','utf8').replace('__BRIDGE_CONFIG__',JSON.stringify(config));
+function fixture(old,ownerCwd='/gateway-a'){
+ let stopped=0,writes=0,starts=0;
+ const payload=JSON.stringify({generation:config.generation,type:'idle',seq:0});
+ const envelope=JSON.stringify({payload,signature:createHmac('sha256',config.token).update(payload).digest('hex')});
+ const api={
+ async getAll(){return [{sessionId:'legacy-session',cwd:ownerCwd,title:'Codex Bridge Connector'}];},
+ async readFileAtCwd(){return {contents:envelope};},
+ async writeSessionFile(){writes++;return {hash:'fixture'};},
+ async start(){starts++;return {sessionId:'new-session'};}
+ };
+ const window={'claude.web':{LocalSessions:api},__claudeMobileBridge:{
+ status:()=>old,stop(){stopped++;},...(old?.owns?{owns:old.owns}:{})}};
+ const context={window,crypto:webcrypto,TextEncoder,Uint8Array,Date,setTimeout,clearTimeout,console:{info(){},error(){}}};
+ return {window,run:()=>vm.runInNewContext(source,context),counts:()=>({stopped,writes,starts})};
+}
+for(const [name,status] of [
+ ['live foreign legacy owner',{transport:'file',stopped:false,busy:false,sessionId:'legacy-session',lastWrite:Date.now()}],
+ ['stale foreign legacy owner',{transport:'file',stopped:false,busy:false,sessionId:'legacy-session',lastWrite:1}],
+ ['live foreign current owner',{transport:'file',stopped:false,busy:false,owns:()=>false,lastWrite:Date.now()}],
+ ['unknown legacy owner',{transport:'file',stopped:false,busy:false,sessionId:'missing',lastWrite:Date.now()}]
+])test(name+' remains intact and reports conflict',async()=>{
+ const ui=fixture(status);
+ await assert.rejects(ui.run(),/其他网关占用/);
+ assert.deepEqual(ui.counts(),{stopped:0,writes:0,starts:0});
+ assert.match(ui.window.__codingRemoteInjectionError,/其他网关占用/);
+});
+test('legacy same workspace may be replaced without exposing its token',async()=>{
+ const ui=fixture({transport:'file',stopped:false,busy:false,sessionId:'legacy-session',lastWrite:Date.now()},config.cwd);
+ const running=ui.run();
+ for(let i=0;i<50&&!ui.window.__claudeMobileBridge?.owns;i++)await new Promise(resolve=>setTimeout(resolve,10));
+ assert.equal(ui.counts().stopped,1);assert.equal(ui.window.__claudeMobileBridge.owns(config.cwd),true);
+ assert.equal(ui.window.__claudeMobileBridge.owns('/gateway-a'),false);
+ assert.ok(!JSON.stringify(ui.window.__claudeMobileBridge.status()).includes(config.token));
+ ui.window.__claudeMobileBridge.stop();await running;
+});
+test('pending request or response write blocks even same-owner replacement',async()=>{
+ for(const field of ['busy','writing']){
+ const ui=fixture({transport:'file',stopped:false,sessionId:'legacy-session',[field]:true},config.cwd);
+ await assert.rejects(ui.run(),/请求进行中/);
+ assert.equal(ui.counts().stopped,0);
+ }
+});
+test('legacy stopped flag alone does not prove a signed release',async()=>{
+ const ui=fixture({transport:'file',stopped:true,busy:false,sessionId:'legacy-session'});
+ await assert.rejects(ui.run(),/其他网关占用/);
+ assert.equal(ui.counts().stopped,0);
+});
diff --git a/tests/claude-connector.test.cjs b/tests/claude-connector.test.cjs
index 9a74b26d..ef358ada 100644
--- a/tests/claude-connector.test.cjs
+++ b/tests/claude-connector.test.cjs
@@ -138,6 +138,6 @@ const waitFor = async predicate => {
await waitFor(()=>response.seq===seq&&response.done);
assert.equal(response.result.contextUsage,null);assert.equal(response.result.contextUsageStatus,'unsupported');
window.__claudeMobileBridge.stop(); await running;
- assert.equal(response.connected,false);
+ assert.equal(window.__claudeMobileBridge.status().stopped,true);
console.log('Desktop file connector: handshake, original session dispatch, deduplication, same-generation reinjection without replay, expiry, allowlist, redaction, restart, replay rejection, stop passed.');
})().catch(e=>{window.__claudeMobileBridge?.stop();console.error(e);process.exitCode=1;});
diff --git a/tests/client-lifecycle-ui.test.cjs b/tests/client-lifecycle-ui.test.cjs
index a7f7e3b6..04f6302d 100644
--- a/tests/client-lifecycle-ui.test.cjs
+++ b/tests/client-lifecycle-ui.test.cjs
@@ -109,6 +109,11 @@ test('connection labels distinguish startup, required action, failure and actual
assert.equal(connection({...base,setupStatus:'recovery-required',connectionState:'connecting',backgroundRunning:true,mainRunning:false}).label,'后台运行,桌面未打开');
assert.equal(connection({...base,pendingEnable:true,reason:'请手动连接 Claude'}).reason,'正在等待客户端连接,完成后会自动显示聊天。');
for(const setupStatus of ['needs-initialization','needs-retry','needs-developer-mode','needs-trust','needs-permission','failed'])assert.equal(connection({...base,id:'claude',setupStatus}).canInitialize,true);
+ const prepared={...base,id:'claude',setupStatus:'needs-initialization',reconnectSupported:true,canReconnect:true};
+ assert.equal(connection(prepared).canReconnect,true);assert.equal(connection(prepared).canInitialize,true);
+ assert.equal(connection({...prepared,connectionState:'connecting'}).canReconnect,false);
+ assert.equal(connection({...prepared,setupStatus:'owner-conflict'}).canReconnect,true);
+ assert.equal(connection({...prepared,canReconnect:false}).canReconnect,false);
for(const client of [{...base,id:'deepseek',setupStatus:'needs-initialization'},{...base,id:'claude',connected:true,setupStatus:'needs-initialization'},{...base,id:'claude',connectionState:'connecting',setupStatus:'needs-initialization'},{...base,id:'claude',setupStatus:'unsupported'}])assert.equal(connection(client).canInitialize,false);
assert.equal(connection({...base,setupStatus:'restart-required',retryable:true}).retryable,false);
});
diff --git a/tests/client-navigation.test.cjs b/tests/client-navigation.test.cjs
index f7d3ca32..b0a6b6b5 100644
--- a/tests/client-navigation.test.cjs
+++ b/tests/client-navigation.test.cjs
@@ -8,8 +8,19 @@ function fixture(){
let initializeChoice=false,finishInitialize;const initializations=[];context.ClientLifecycle.chooseInitialize=client=>{initializations.push(client.id);return initializeChoice==='pending'?new Promise(resolve=>finishInitialize=resolve):initializeChoice;};
const C=vm.runInContext('ClientNavigation',context),nav=Object.create(C.prototype),painted=[];
Object.assign(nav,{clients:[{id:'codex',enabled:true,configured:true},{id:'claude',enabled:true,configured:true},{id:'deepseek',enabled:true,configured:true}],provider:'claude',revision:0,pending:new Set(),notify(){},rememberSwipeList(){},paint(){painted.push(this.clients.filter(c=>c.enabled).map(c=>c.id));},view:{provider:'claude',choose(id){this.provider=id;return Promise.resolve();}}});
- return {nav,painted,choices,document:context.document,setChoice:value=>choice=value,decide:value=>finishChoice(value),mobile:value=>mobile=value,initializations,setInitializeChoice:value=>initializeChoice=value,decideInitialize:value=>finishInitialize(value)};
+ return {nav,painted,choices,document:context.document,window:context.window,setChoice:value=>choice=value,decide:value=>finishChoice(value),mobile:value=>mobile=value,initializations,setInitializeChoice:value=>initializeChoice=value,decideInitialize:value=>finishInitialize(value)};
}
+test('notification settings stay hidden on shared relay while remaining actions use all columns',()=>{
+ const ui=fixture(),classes=new Set();
+ ui.nav.switchToggle={hidden:false};ui.nav.settingsNotifications={hidden:false};
+ ui.nav.settingsActions={classList:{toggle(name,enabled){if(enabled)classes.add(name);else classes.delete(name);}}};
+ ui.window.BridgeSharedRelay=false;ui.nav.paintSettingsActions();
+ assert.equal(ui.nav.settingsNotifications.hidden,false);assert.equal(classes.size,0);
+ ui.window.BridgeSharedRelay=true;ui.nav.paintSettingsActions();
+ assert.equal(ui.nav.settingsNotifications.hidden,true);assert.equal(classes.has('two-actions'),true);
+ ui.nav.switchToggle.hidden=true;ui.nav.paintSettingsActions();
+ assert.equal(classes.has('two-actions'),false);assert.equal(classes.has('one-action'),true);
+});
test('changing provider paints the shell before its slow read resolves',async()=>{
const {nav,painted}=fixture();let resolve;
nav.view.choose=()=>new Promise(done=>resolve=done);
diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs
index 10c38d3c..e7f8f1a8 100644
--- a/tests/desktop-sessions-ui.test.cjs
+++ b/tests/desktop-sessions-ui.test.cjs
@@ -5,6 +5,7 @@ function fixture(){
class Node {
constructor(tag='div'){this.tagName=tag.toUpperCase();this.children=[];this.parentElement=null;this.dataset={};this.attributes={};this.hidden=false;this.disabled=false;this.value='';this.className='';this._text='';this.style={setProperty(){}};this.scrollHeight=100;this.scrollTop=0;this.clientHeight=100;this.clientWidth=1000;this.open=false;this.options=this.children;this.classList={contains:n=>this.className.split(' ').includes(n),add:(...ns)=>this.className=[...new Set([...this.className.split(' ').filter(Boolean),...ns])].join(' '),remove:(...ns)=>this.className=this.className.split(' ').filter(n=>!ns.includes(n)).join(' '),toggle:(n,v)=>{v=v??!this.classList.contains(n);this.classList[v?'add':'remove'](n);return v;}};}
get textContent(){return this._text+this.children.map(n=>n.textContent).join('');}set textContent(v){this._text=String(v??'');this.children=[];this.options=this.children;}
+ getBoundingClientRect(){return {width:this.clientWidth,top:0,bottom:this.clientHeight};}
get firstElementChild(){return this.children[0];}get lastChild(){return this.children.at(-1);}get childNodes(){return this.children;}get isConnected(){return !!this.parentElement;}
append(...nodes){for(let n of nodes){if(typeof n==='string')n=document.createTextNode(n);n.remove();n.parentElement=this;this.children.push(n);}}
prepend(...nodes){for(const n of [...nodes].reverse()){n.remove();n.parentElement=this;this.children.unshift(n);}}
@@ -22,20 +23,30 @@ function fixture(){
}
document={body:new Node('body'),documentElement:new Node('html'),hidden:false,activeElement:null,createElement:tag=>new Node(tag),createElementNS:(_,tag)=>new Node(tag),createTextNode:text=>{const n=new Node('#text');n.textContent=text;return n;},querySelectorAll:s=>document.body.querySelectorAll(s),querySelector:s=>document.body.querySelector(s),getElementById:id=>document.body.querySelector('#'+id),addEventListener:(type,fn)=>listeners[type]=fn,dispatchEvent:event=>listeners[event.type]?.(event)};
const root=new Node('div');root.className='app';document.body.append(root);const select=new Node('select'),calls=[],notices=[];let uploadHandler=async()=>({ok:true,status:200,json:async()=>({image:true,size:16,name:'image.png'})}),handler=async(url,body)=>body?{status:'accepted'}:url.includes('/detail')?{session:{id:'a',title:'Chat',model:'model-a',effort:'high',status:'idle',capabilities:{}},messages:[]}:url.includes('/workspace')?{entries:[],total:0,project:'/project'}:{sessions:[]};
- const context=vm.createContext({document,window:{},navigator:{},location:{pathname:'/'},history:{replaceState(){}},localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},sessionStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},crypto:{getRandomValues:array=>webcrypto.getRandomValues(array)},Uint8Array,TextEncoder,Option:class extends Node{constructor(text,value){super('option');this.textContent=text;this.value=value;}},ResizeObserver:class{observe(){}},MutationObserver:class{observe(){}},AbortController,requestAnimationFrame:fn=>fn(),getComputedStyle:()=>({getPropertyValue:()=>8}),setTimeout(){return 1;},clearTimeout(){},fetch:async(url,options)=>{calls.push({url,upload:options});return uploadHandler(url,options);},renderMarkdown:(node,text)=>node.textContent=text,CustomEvent:class{constructor(type,options){this.type=type;Object.assign(this,options);}}});
+ const context=vm.createContext({document,window:{addEventListener(){}},navigator:{},location:{pathname:'/'},history:{replaceState(){}},localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},sessionStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},crypto:{getRandomValues:array=>webcrypto.getRandomValues(array)},Uint8Array,TextEncoder,Option:class extends Node{constructor(text,value){super('option');this.textContent=text;this.value=value;}},ResizeObserver:class{observe(){}},MutationObserver:class{observe(){}},AbortController,requestAnimationFrame:fn=>fn(),cancelAnimationFrame(){},getComputedStyle:()=>({getPropertyValue:()=>8}),setTimeout(){return 1;},clearTimeout(){},fetch:async(url,options)=>{calls.push({url,upload:options});return uploadHandler(url,options);},renderMarkdown:(node,text)=>node.textContent=text,CustomEvent:class{constructor(type,options){this.type=type;Object.assign(this,options);}}});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../web/shell/app.js'),'utf8').match(/^function uuid\(\).*$/m)[0],context);
- for(const file of ['web/layouts/viewport.js','web/features/chat/activity.js','web/features/chat/attachments.js','web/shared/i18n.js','web/features/clients/client-lifecycle.js','web/features/chat/message-actions.js','web/features/chat/permissions.js','web/features/workspace/workbench.js','web/features/clients/desktop-sessions.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'..',file),'utf8'),context);
+ for(const file of ['web/layouts/viewport.js','web/features/chat/activity.js','web/features/chat/attachments.js','web/shared/i18n.js','web/features/clients/client-lifecycle.js','web/features/chat/message-actions.js','web/features/chat/permissions.js','web/features/workspace/workbench.js','web/features/chat/list-sync.js','web/features/clients/desktop-sessions.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'..',file),'utf8'),context);
const View=vm.runInContext('DesktopSessionsView',context),view=new View({root,select,request:(url,body)=>{calls.push({url,body});return handler(url,body);},notify:text=>notices.push(text),onSelect(){},csrf:()=>'csrf-test',onUnauthorized(){}});
return {view,root,document,calls,notices,setHandler:fn=>handler=fn,setUploadHandler:fn=>uploadHandler=fn,run:s=>vm.runInContext(s,context),text:n=>n.textContent};
}
async function opened(){const ui=fixture();await ui.view.choose('claude');await ui.view.open('a');return ui;}
+test('search stays short in both languages while its accessible name stays specific',()=>{
+ const ui=fixture();
+ assert.equal(ui.view.search.placeholder,'搜索');
+ assert.equal(ui.view.search.getAttribute('aria-label'),'搜索聊天或项目');
+ ui.run("BridgeI18n.setLanguage('en');BridgeI18n.apply()");
+ assert.equal(ui.view.search.placeholder,'Search');
+ assert.equal(ui.view.search.dataset.i18nAriaLabel,'搜索聊天或项目');
+ assert.equal(ui.run("BridgeI18n.t('搜索聊天或项目')"),'Search chats or projects');
+});
+
test('session list explains a pending connection and offers retry only after timeout',async()=>{
const ui=fixture(),client={id:'deepseek',enabled:true,configured:true,running:false,connected:false,connectionState:'starting',reason:'正在启动 Harness 桌面应用'},retries=[];
ui.view.setClientStates([client],async id=>retries.push(id));ui.setHandler(async()=>{throw Error('客户端连接不可用,请在电脑端检查');});await ui.view.choose('deepseek');
assert.match(ui.view.listStatus.textContent,/正在启动/);assert.doesNotMatch(ui.view.listStatus.textContent,/请在电脑端检查/);assert.equal(ui.view.listStatus.attributes['aria-busy'],'true');assert.equal(ui.view.connectionRetry.hidden,true);assert.equal(ui.calls.length,0);
ui.view.setClientStates([{...client,connectionState:'timeout',retryable:true,reason:'Harness 连接超时,请检查桌面应用后重试接入'}],async id=>retries.push(id));await ui.view.refresh();assert.match(ui.view.listStatus.textContent,/连接超时/);assert.equal(ui.view.listStatus.attributes['aria-busy'],'false');assert.equal(ui.view.connectionRetry.hidden,false);await ui.view.connectionRetry.click();assert.deepEqual(retries,['deepseek']);
- ui.setHandler(async()=>({connected:true,sessions:[]}));await ui.view.setClientStates([{...client,connectionState:'connected',connected:true}]);assert.match(ui.view.listStatus.textContent,/已连接/);assert.equal(ui.view.connectionRetry.hidden,true);
+ ui.setHandler(async()=>({connected:true,sessions:[]}));await ui.view.setClientStates([{...client,connectionState:'connected',connected:true}]);assert.equal(ui.view.listStatus.textContent,'电脑上还没有聊天');assert.equal(ui.view.connectionRetry.hidden,true);
});
test('Claude session list exposes the confirmed initialization flow without running chat RPCs',async()=>{
@@ -45,16 +56,35 @@ test('Claude session list exposes the confirmed initialization flow without runn
ui.view.setClientStates([{...client,connectionState:'connecting'}]);assert.equal(ui.view.connectionInitialize.hidden,true);await ui.view.refresh();assert.equal(ui.calls.length,0);
});
+test('prepared Claude lists offer separate reconnect and reinitialization actions',async()=>{
+ const ui=fixture(),client={id:'claude',name:'Claude',enabled:true,installed:true,connected:false,setupStatus:'needs-initialization',reconnectSupported:true,canReconnect:true},reconnects=[],initializations=[];
+ ui.view.setClientStates([client],undefined,id=>initializations.push(id),undefined,id=>reconnects.push(id));await ui.view.choose('claude');
+ assert.equal(ui.view.connectionReconnect.hidden,false);assert.equal(ui.view.connectionInitialize.hidden,false);
+ assert.equal(ui.view.connectionInitialize.textContent,'重新初始化连接');
+ await ui.view.connectionReconnect.click();assert.deepEqual(reconnects,['claude']);assert.deepEqual(initializations,[]);
+ await ui.view.connectionInitialize.click();assert.deepEqual(initializations,['claude']);
+ ui.view.setClientStates([{...client,operationUncertain:true}],undefined,()=>{throw Error('unexpected initialize');},undefined,()=>{throw Error('unexpected reconnect');});
+ assert.equal(ui.view.connectionReconnect.hidden,true);assert.equal(ui.view.connectionInitialize.hidden,true);
+ ui.view.setClientStates([{...client,setupStatus:'owner-conflict',reason:'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude'}]);
+ assert.match(ui.view.listStatus.textContent,/其他网关占用/);assert.equal(ui.view.connectionReconnect.hidden,false);
+});
+
test('unknown operations offer refresh only and a failed quit does not block connected chats',async()=>{
const ui=fixture(),client={id:'claude',enabled:true,installed:true,connected:false,initializationMode:'background',setupStatus:'needs-unlock',connectionState:'needs-initialization',operationFailure:{action:'initialize',message:'timeout',uncertain:true}},refreshes=[];
ui.view.setClientStates([client],()=>{throw Error('no replay');},()=>{throw Error('no initialization');},async()=>refreshes.push(true));await ui.view.choose('claude');assert.match(ui.view.listStatus.textContent,/电脑已锁定/);assert.match(ui.view.connectionNotice.textContent,/尚未确认/);assert.equal(ui.view.connectionInitialize.hidden,true);assert.equal(ui.view.connectionRetry.hidden,true);assert.equal(ui.view.connectionRefresh.hidden,false);await ui.view.connectionRefresh.click();assert.equal(refreshes.length,1);assert.equal(ui.calls.length,0);
- await ui.view.setClientStates([{...client,connected:true,connectionState:'connected',operationFailure:{action:'quit',message:'退出未完成',uncertain:false}}]);assert.ok(ui.calls.some(call=>call.url.includes('/list')));assert.match(ui.view.listStatus.textContent,/已连接/);assert.equal(ui.view.connectionNotice.textContent,'退出未完成');assert.equal(ui.view.connectionInitialize.hidden,true);
+ await ui.view.setClientStates([{...client,connected:true,connectionState:'connected',operationFailure:{action:'quit',message:'退出未完成',uncertain:false}}]);assert.ok(ui.calls.some(call=>call.url.includes('/list')));assert.equal(ui.view.listStatus.textContent,'电脑上还没有聊天');assert.equal(ui.view.connectionNotice.textContent,'退出未完成');assert.equal(ui.view.connectionInitialize.hidden,true);
});
test('session list surfaces genuine failures outside startup and never claims disconnected data is connected',async()=>{
const ui=fixture();ui.view.setClientStates([{id:'deepseek',enabled:true,connected:false,connectionState:'idle'}]);ui.setHandler(async()=>{throw Error('real failure');});await ui.view.choose('deepseek');assert.equal(ui.view.listStatus.textContent,'real failure');
ui.setHandler(async()=>({connected:false,sessions:[]}));await ui.view.refresh();assert.equal(ui.view.listStatus.textContent,'尚未连接');
});
+test('desktop client keeps cached rows through brief polling failures and clears sustained failure after recovery',async()=>{
+ const ui=fixture();ui.setHandler(async()=>({sessions:[{id:'a',title:'Saved chat',backend:'code'}]}));await ui.view.choose('claude');
+ ui.setHandler(async()=>{throw Error('network unavailable');});
+ for(let attempt=1;attempt<=3;attempt++){await assert.rejects(ui.view.refreshList(),/network unavailable/);assert.match(ui.view.rowsRoot.textContent,/Saved chat/);assert.equal(ui.view.listStatus.textContent,'');assert.equal(ui.view.listHint.hidden,attempt<3);}
+ ui.setHandler(async()=>({sessions:[{id:'a',title:'Current chat',backend:'code'}]}));await ui.view.refreshList();assert.match(ui.view.rowsRoot.textContent,/Current chat/);
+});
test('LAN HTTP send creates a secure id and retries the same unconfirmed operation',async()=>{
const ui=await opened();ui.run('uuid=undefined');let attempt=0;ui.setHandler(async(url,body)=>{if(body){if(++attempt===1)throw Error('connection lost');return {status:'accepted'};}return url.includes('/detail')?{session:{id:'a',title:'Chat',status:'idle'},messages:[]}:{sessions:[]};});
ui.view.input.value='hello';await ui.view.submit();assert.equal(ui.view.input.value,'hello');await ui.view.detail(ui.view.generation);await ui.view.submit();const writes=ui.calls.filter(c=>c.body);assert.equal(writes.length,2);assert.match(writes[0].body.id,/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);assert.equal(writes[0].body.id,writes[1].body.id);assert.equal(ui.view.input.value,'');
@@ -74,6 +104,32 @@ test('Claude lists distinguish Code and Cowork and retain archive/project filter
test('workspace tabs use the real Workbench provider routes and split behavior',async()=>{
const ui=await opened();assert.equal(ui.view.workbench.current.provider,'claude');ui.view.workbench.files();await Promise.resolve();assert.ok(ui.calls.some(c=>c.url.includes('/api/desktop-sessions/claude/workspace?sessionId=a')));ui.view.workbench.split('files');assert.equal(ui.view.workbench.splitMode,true);assert.ok(ui.view.conversation.classList.contains('wb-split'));assert.equal(ui.view.workbench.current.splitTab,'files');
});
+
+for(const [closed,next] of [['files','terminal'],['terminal','file:preview'],['file:preview','terminal']])test(`closing active split ${closed} selects adjacent ${next} and preserves the main chat`,async()=>{
+ const ui=await opened(),wb=ui.view.workbench,session=wb.current,disposed=[];
+ session.files=['files','terminal','file:preview'].map(id=>({id,name:id,body:wb.node('div'),terminal:id==='terminal'?{dispose:()=>disposed.push(id)}:undefined}));
+ ui.view.input.value='unsent draft';ui.view.messages.scrollTop=42;wb.split(closed);session.splitRatio=.6;
+ await wb.close(closed);assert.equal(session.splitTab,next);assert.equal(session.active,next);assert.equal(wb.splitMode,true);assert.equal(wb.panel.firstElementChild,session.files.find(t=>t.id===next).body);assert.equal(ui.view.input.value,'unsent draft');assert.equal(ui.view.messages.scrollTop,42);assert.equal(session.splitRatio,.6);assert.deepEqual(disposed,closed==='terminal'?['terminal']:[]);
+});
+test('inactive closes and consecutive closes preserve split until the final tool is closed',async()=>{
+ const ui=await opened(),wb=ui.view.workbench,s=wb.current;
+ s.files=['files','terminal','file:preview'].map(id=>({id,name:id,body:wb.node('div')}));wb.split('terminal');
+ await wb.close('files');assert.equal(s.splitTab,'terminal');await wb.close('terminal');assert.equal(s.splitTab,'file:preview');assert.equal(wb.splitMode,true);
+ await wb.close('file:preview');assert.equal(s.splitTab,null);assert.equal(s.active,'chat');assert.equal(wb.splitMode,false);assert.equal(wb.panel.hidden,true);
+});
+test('closing a split tool while narrow preserves the adjacent tool when widened',async()=>{
+ const ui=await opened(),wb=ui.view.workbench,s=wb.current;
+ s.files=['files','file:preview'].map(id=>({id,name:id,body:wb.node('div')}));wb.split('file:preview');ui.view.conversation.clientWidth=390;wb.paint();
+ await wb.close('file:preview');assert.equal(s.splitTab,'files');assert.equal(wb.splitMode,false);ui.view.conversation.clientWidth=1000;wb.paint();assert.equal(wb.splitMode,true);assert.equal(wb.panel.firstElementChild,s.files[0].body);
+ wb.unsplit();await wb.close('files');assert.equal(s.active,'chat');assert.equal(s.splitTab,null);
+});
+test('a pending side-chat close updates only its original session and respects cancellation',async()=>{
+ const ui=await opened(),wb=ui.view.workbench,s=wb.current;let finish;
+ s.files=['files','sidechat'].map(id=>({id,name:id,body:wb.node('div')}));s.files[1].sideChat={end:()=>new Promise(resolve=>finish=resolve),dispose(){}};wb.split('sidechat');
+ const cancelled=wb.close('sidechat');finish(false);await cancelled;assert.equal(s.splitTab,'sidechat');assert.equal(s.files.length,2);
+ const closing=wb.close('sidechat');wb.open('other','local');const other=wb.current;finish(true);await closing;assert.equal(wb.current,other);assert.equal(s.splitTab,'files');assert.equal(s.active,'files');assert.equal(other.active,'chat');
+});
+
test('model picker preserves current effort only for compatible models',async()=>{
const ui=await opened();ui.setHandler(async()=>({models:[{id:'model-a',efforts:['high','low']},{id:'model-b',efforts:['low'],defaultEffort:'low'}],currentModel:'model-a',currentEffort:'high'}));await ui.view.models();const dialog=ui.document.querySelector('dialog'),selects=dialog.querySelectorAll('select');assert.equal(selects[1].value,'high');selects[0].value='model-b';selects[0].onchange();assert.equal(selects[1].value,'low');
});
@@ -218,7 +274,7 @@ test('history caching evicts older chats and does not retain an oversized transc
test('an invalidated list failure cannot replace the status of a newer successful read',async()=>{
const ui=await opened();let rejectOld,reads=0;ui.setHandler((url,body)=>body?Promise.resolve({status:'accepted'}):url.includes('/list')?++reads===1?new Promise((_,reject)=>rejectOld=reject):Promise.resolve({sessions:[{id:'a',title:'Fresh list',backend:'code'}]}):Promise.resolve({session:{id:'a',title:'Fresh',status:'idle'},messages:[]}));
const stale=ui.view.refresh();await new Promise(setImmediate);await ui.view.mutate('settings',{model:'new'});await new Promise(setImmediate);rejectOld(Error('obsolete list failure'));await stale;
- assert.match(ui.view.rowsRoot.textContent,/Fresh list/);assert.equal(ui.view.listStatus.textContent,'已连接桌面');
+ assert.match(ui.view.rowsRoot.textContent,/Fresh list/);assert.equal(ui.view.listStatus.textContent,'');
});
test('account changes, disabling and logout reject old background list and history replies',async()=>{
@@ -241,7 +297,7 @@ test('reads started before and during a write never replace its completion refre
test('an old account write cannot invalidate the new account list refresh',async()=>{
const ui=await opened();let accept,finishList;ui.setHandler((url,body)=>body?new Promise(resolve=>accept=resolve):Promise.resolve({sessions:[]}));const writing=ui.view.mutate('settings',{model:'new'});
ui.setHandler(()=>new Promise(resolve=>finishList=resolve));const changing=ui.view.accountChanged('claude',{activeId:'old'},{activeId:'new'});accept({status:'accepted'});await writing;
- finishList({sessions:[{id:'b',title:'New account list',backend:'code'}]});await changing;assert.match(ui.view.rowsRoot.textContent,/New account list/);assert.equal(ui.view.listStatus.textContent,'已连接桌面');
+ finishList({sessions:[{id:'b',title:'New account list',backend:'code'}]});await changing;assert.match(ui.view.rowsRoot.textContent,/New account list/);assert.equal(ui.view.listStatus.textContent,'');
});
test('provider return paints cached list and selected history before either network response',async()=>{
@@ -328,3 +384,31 @@ test('context monitor remains discoverable and distinguishes unsupported from mi
apply(null,'unavailable');ui.view.contextDetails();dialog=ui.document.querySelector('dialog');assert.match(dialog.textContent,language==='en'?/update automatically when available/:/有可用数据时会自动更新/);assert.doesNotMatch(dialog.textContent,language==='en'?/does not expose/:/未提供上下文用量接口/);
}
});
+
+for(const provider of ['claude','deepseek'])test(`${provider} manual refresh joins polling and foreground reads and waits for the list result`,async()=>{
+ const ui=fixture();await ui.view.choose(provider);let finish;ui.setHandler(()=>new Promise(resolve=>finish=resolve));const before=ui.calls.length;
+ const polling=ui.view.refresh(),manual=ui.view.listSync.update(true);ui.document.dispatchEvent({type:'visibilitychange'});assert.equal(ui.calls.length-before,1);assert.match(ui.view.listHint.textContent,/正在刷新/);assert.equal(ui.view.listSync.busy,true);
+ finish({sessions:[{id:'new',title:'New result',backend:'code'}]});await Promise.all([polling,manual]);await new Promise(setImmediate);assert.equal(ui.calls.length-before,1,'no redundant trailing list read');assert.match(ui.view.rowsRoot.textContent,/New result/);assert.match(ui.view.listHint.textContent,/已更新/);assert.equal(ui.calls.some(c=>c.body),false);
+});
+test('manual refresh failure and disconnected responses remain retryable without fetching a selected chat',async()=>{
+ const ui=await opened(),before=ui.calls.length;
+ ui.setHandler(async()=>{throw Error('offline');});await ui.view.listSync.update(true);assert.match(ui.view.listHint.textContent,/点击重试/);assert.equal(ui.view.sid,'a');
+ ui.setHandler(async()=>({connected:false,sessions:[]}));await ui.view.listHint.click();assert.match(ui.view.listHint.textContent,/点击重试/);
+ ui.setHandler(async()=>({sessions:[{id:'a',title:'Recovered',backend:'code'}]}));await ui.view.listHint.click();assert.match(ui.view.listHint.textContent,/已更新/);assert.match(ui.view.rowsRoot.textContent,/Recovered/);
+ assert.ok(ui.calls.slice(before).every(c=>c.url.endsWith('/list')&&!c.body));
+});
+test('manual refresh retains current search, filters, selected chat, draft and list position',async()=>{
+ const ui=await opened();ui.view.input.value='draft';ui.view.search.value='needle';ui.view.surface.value='code';ui.view.listMode.value='project';ui.view.archived.checked=true;ui.view.rowsRoot.scrollTop=80;
+ let finish;ui.setHandler(()=>new Promise(resolve=>finish=resolve));const pending=ui.view.listSync.update(true);ui.view.search.value='changed';finish({sessions:[{id:'a',title:'changed result',backend:'code',cwd:'/project',archived:true},{id:'b',title:'needle old filter',backend:'cowork'}]});await pending;
+ assert.equal(ui.view.search.value,'changed');assert.equal(ui.view.surface.value,'code');assert.equal(ui.view.listMode.value,'project');assert.equal(ui.view.archived.checked,true);assert.equal(ui.view.sid,'a');assert.equal(ui.view.input.value,'draft');assert.equal(ui.view.rowsRoot.scrollTop,80);assert.match(ui.view.rowsRoot.textContent,/changed result/);assert.doesNotMatch(ui.view.rowsRoot.textContent,/old filter/);
+});
+for(const failure of [false,true])test(`a previous provider refresh ${failure?'failure':'success'} cannot affect a new provider refresh`,async()=>{
+ const ui=fixture();await ui.view.choose('claude');let resolveOld,rejectOld,resolveNew;ui.setHandler(url=>url.includes('/claude/')?new Promise((resolve,reject)=>{resolveOld=resolve;rejectOld=reject;}):new Promise(resolve=>resolveNew=resolve));
+ const old=ui.view.listSync.update(true),switching=ui.view.choose('deepseek');assert.equal(ui.view.listHint.hidden,true);const current=ui.view.listSync.update(true);assert.equal(ui.view.listSync.busy,true);
+ if(failure)rejectOld(Error('old failure'));else resolveOld({sessions:[{id:'old',title:'Old provider'}]});await old;assert.equal(ui.view.listSync.busy,true);assert.match(ui.view.listHint.textContent,/正在刷新/);assert.doesNotMatch(ui.view.rowsRoot.textContent,/Old provider/);
+ resolveNew({sessions:[{id:'new',title:'New provider'}]});await Promise.all([switching,current]);assert.equal(ui.view.provider,'deepseek');assert.match(ui.view.rowsRoot.textContent,/New provider/);assert.match(ui.view.listHint.textContent,/已更新/);
+});
+test('account invalidation cancels manual feedback and rejects old list contents',async()=>{
+ const ui=fixture();await ui.view.choose('claude');let finish;ui.setHandler(()=>new Promise(resolve=>finish=resolve));const old=ui.view.listSync.update(true);ui.view.invalidateReads('claude');assert.equal(ui.view.listHint.hidden,true);
+ finish({sessions:[{id:'old',title:'Old account',backend:'code'}]});await old;assert.equal(ui.view.listHint.hidden,true);assert.doesNotMatch(ui.view.rowsRoot.textContent,/Old account/);
+});
diff --git a/tests/desktop.test.cjs b/tests/desktop.test.cjs
index 29dbaee1..e9f1c0ee 100644
--- a/tests/desktop.test.cjs
+++ b/tests/desktop.test.cjs
@@ -106,22 +106,23 @@ for(const transferFirst of [false,true])test(`independent transfer settings ${tr
element.validity={valid:true};element.reportValidity=()=>element.validity.valid;
return element;
};
- const panel=ui.context.document.createElement('section');panel.dataset.panel='advanced';
+ const panel=ui.context.document.createElement('section');panel.dataset.panel='advanced';panel.hidden=true;
const descendants=element=>[element,...(element.children||[]).flatMap(descendants)];
const autoStart=ui.nodes.get('auto-start');autoStart.id='auto-start';autoStart.type='checkbox';autoStart.validity={valid:true};autoStart.closest=selector=>selector==='[data-panel]'?panel:null;
ui.context.document.querySelector=selector=>selector==='[data-panel=advanced]'?panel:null;
ui.nodes.get('settings').querySelectorAll=()=>[autoStart,...descendants(panel).filter(element=>element.tagName==='INPUT')];
- ui.context.MutationObserver=class {observe(){}};ui.context.window.addEventListener=()=>{};
+ let reveal;ui.context.MutationObserver=class {constructor(callback){reveal=callback;}observe(){}};ui.context.window.addEventListener=()=>{};
let resolveRead;const writes=[];
ui.api.transferSettings=value=>value?(writes.push(value),Promise.resolve(value)):new Promise(resolve=>{resolveRead=resolve;});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../web/features/workspace/file-actions.js'),'utf8'),ui.context);
ui.context.BridgeFileActions=ui.context.window.BridgeFileActions;
vm.runInContext(fs.readFileSync(path.join(__dirname,'../desktop/features/files/settings.js'),'utf8'),ui.context);
const input=descendants(panel).find(element=>element.tagName==='INPUT');
- if(transferFirst){resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
+ assert.equal(resolveRead,undefined,'hidden settings must not read before reveal');
+ if(transferFirst){panel.hidden=false;reveal();resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
ui.run('dirty=false;render(snapshot)');
assert.equal(ui.run('dirty'),false);
- if(!transferFirst){resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
+ if(!transferFirst){panel.hidden=false;reveal();resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
ui.run("applyLanguage('en')");
assert.equal(ui.run('dirty'),false,'independently loaded transfer limit is not a gateway draft');
await ui.start();assert.ok(ui.calls.includes('start'));
diff --git a/tests/list-sync.test.cjs b/tests/list-sync.test.cjs
index aa6a61f2..96f1ff27 100644
--- a/tests/list-sync.test.cjs
+++ b/tests/list-sync.test.cjs
@@ -5,11 +5,11 @@ function fixture(){
let now=0,id=0,calls=0,ready=true,visible=true,refresh=async()=>{};
const timers=new Map(),frames=new Map(),events={};
const node=()=>({hidden:true,textContent:'',scrollTop:0,style:{setProperty(key,value){this[key]=value;}},classList:{values:new Set(),add(key){this.values.add(key);},remove(key){this.values.delete(key);},toggle(key,on){on?this.add(key):this.remove(key);}},setAttribute(key,value){this[key]=value;},addEventListener(type,fn){(events[type]??=[]).push(fn);}});
- const root=node(),status=node();let writes=0,text='';Object.defineProperty(status,'textContent',{get:()=>text,set:value=>{writes++;text=value;}});
+ const root=node(),status=node();root.children=[];let writes=0,text='';Object.defineProperty(status,'textContent',{get:()=>text,set:value=>{writes++;text=value;}});
const context=vm.createContext({BridgeI18n:{t:value=>value},document:{hidden:false,addEventListener(){}},window:{addEventListener(){}},
setTimeout:(fn,delay)=>{timers.set(++id,{fn,at:now+delay});return id;},clearTimeout:key=>timers.delete(key),requestAnimationFrame:fn=>{frames.set(++id,fn);return id;},cancelAnimationFrame:key=>frames.delete(key)});
vm.runInContext(fs.readFileSync(require.resolve('../web/features/chat/list-sync.js'),'utf8'),context);
- const sync=new (vm.runInContext('ListSync',context))({root,status,visible:()=>visible,ready:()=>ready,refresh:async()=>{calls++;await refresh();}});
+ const sync=new (vm.runInContext('ListSync',context))({root,status,visible:()=>visible,ready:()=>ready,refresh:async()=>{calls++;return await refresh();}});
const frame=()=>{const tasks=[...frames.values()];frames.clear();for(const fn of tasks)fn();};
const advance=ms=>{const end=now+ms;while(true){const next=[...timers].filter(([,timer])=>timer.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;now=next[1].at;timers.delete(next[0]);next[1].fn();}now=end;};
const touch=(type,y,x=30,count=1)=>{const event={touches:['touchend','touchcancel'].includes(type)?[]:Array.from({length:count},()=>({clientX:x,clientY:y})),preventDefault(){this.prevented=true;}};for(const fn of events[type]||[])fn(event);return event;};
@@ -59,3 +59,35 @@ test('busy, hidden and unavailable lists reject pull gestures and failure remain
ui.setRefresh(async()=>{throw Error('offline');});await ui.sync.update(true);ui.advance(5000);assert.equal(ui.status.hidden,false);assert.match(ui.status.textContent,/点击重试/);
ui.setRefresh(async()=>{});ui.status.onclick();await settled();assert.equal(ui.calls,3);ui.advance(800);assert.equal(ui.status.hidden,true);
});
+
+test('pulling in the middle of the list does not intercept normal scrolling',async()=>{
+ const ui=fixture();ui.root.scrollTop=120;ui.touch('touchstart',20);const event=ui.touch('touchmove',120);ui.touch('touchend',120);ui.frame();await settled();assert.equal(event.prevented,undefined);assert.equal(ui.calls,0);assert.equal(ui.status.hidden,true);
+});
+test('reset discards old feedback and keeps a newer refresh busy',async()=>{
+ const ui=fixture();let finishOld,finishNew;ui.setRefresh(()=>new Promise(resolve=>finishOld=resolve));const old=ui.sync.update(true);ui.sync.reset();assert.equal(ui.status.hidden,true);
+ ui.setRefresh(()=>new Promise(resolve=>finishNew=resolve));const current=ui.sync.update(true);finishOld();await old;assert.equal(ui.sync.busy,true);assert.match(ui.status.textContent,/正在刷新/);finishNew();await current;assert.equal(ui.sync.busy,false);assert.match(ui.status.textContent,/已更新/);
+});
+
+test('transient automatic failures preserve an old list quietly, then offer retry',async()=>{
+ const ui=fixture();ui.root.children.push({});ui.setRefresh(async()=>{throw Error('offline');});
+ await ui.sync.update();await ui.sync.update();assert.equal(ui.status.hidden,true);
+ await ui.sync.update();assert.equal(ui.status.hidden,false);assert.match(ui.status.textContent,/点击重试/);
+ ui.setRefresh(async()=>{});await ui.sync.update();ui.advance(200);assert.equal(ui.status.hidden,true);
+});
+
+test('manual failure clears on another authoritative success and auth failure uses login flow',async()=>{
+ const ui=fixture();ui.root.children.push({});ui.setRefresh(async()=>{throw Error('offline');});await ui.sync.update(true);assert.equal(ui.status.hidden,false);
+ ui.sync.acknowledgeSuccess();ui.advance(200);assert.equal(ui.status.hidden,true);
+ ui.setRefresh(async()=>{throw Object.assign(Error('login'),{status:401});});await ui.sync.update(true);assert.equal(ui.status.hidden,false);ui.advance(200);assert.equal(ui.status.hidden,true);
+});
+
+test('automatic success hides an old error when the list reader acknowledges success before returning',async()=>{
+ const ui=fixture();ui.root.children.push({});ui.setRefresh(async()=>{throw Error('offline');});await ui.sync.update(true);assert.equal(ui.status.hidden,false);
+ ui.setRefresh(async()=>{ui.sync.acknowledgeSuccess();});await ui.sync.update();ui.advance(200);
+ assert.equal(ui.status.hidden,true);
+});
+
+test('stale refresh results never dismiss current feedback or claim success',async()=>{
+ const ui=fixture();let finish;ui.setRefresh(()=>new Promise(resolve=>finish=resolve));const stale=ui.sync.update(true);ui.sync.reset();ui.setRefresh(async()=>{throw Error('new failure');});await ui.sync.update(true);finish(false);await stale;
+ assert.equal(ui.status.hidden,false);assert.match(ui.status.textContent,/点击重试/);
+});
diff --git a/tests/notifications-ui.test.cjs b/tests/notifications-ui.test.cjs
index 26129a0e..74c5479a 100644
--- a/tests/notifications-ui.test.cjs
+++ b/tests/notifications-ui.test.cjs
@@ -7,16 +7,17 @@ async function fixture(){
const nodes=new Map(),saved=new Map(),writes=[],listRequests=[];
const node=(tag='')=>({tagName:tag.toUpperCase(),children:[],value:'',checked:false,disabled:false,hidden:false,open:false,textContent:'',dataset:{},options:[],style:{setProperty(k,v){this[k]=v;}},scrollTop:0,parentElement:{},listeners:{},
setAttribute(k,v){this[k]=v;},after(child){this.nextSibling=child;},getBoundingClientRect(){return {width:390,height:844};},classList:{add(){},remove(){},toggle(){},contains(){return false;}},addEventListener(type,listener){(this.listeners[type]??=[]).push(listener);},dispatchEvent(event){for(const listener of this.listeners[event.type]||[])listener(event);},replaceChildren(...values){this.children=values;},append(...values){this.children.push(...values);},querySelectorAll(){return [];},querySelector(){return {disabled:false};},
- showModal(){this.open=true;},close(){this.open=false;}});
+ showModal(){this.open=true;},close(){this.open=false;this.dispatchEvent({type:'close'});}});
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
for(const match of html.matchAll(/\bid="([^"]+)"/g))nodes.set(match[1],node());
+ const channelButtons=['bark','ntfy','pushplus'].map(channel=>Object.assign(node('button'),{dataset:{notificationChannel:channel}}));
const storage=()=>({getItem(){return null;},setItem(){},removeItem(){}});
const archiveToggles=[];
const archiveFilter={classList:{toggle:(name,enabled)=>archiveToggles.push([name,enabled])}};
- let nextPost;
+ let nextPost,nextGet;
const response=(data,status=200)=>({ok:status===200,status,json:async()=>data});
let listResponse=()=>response({sessions:[],unavailableHosts:[]});
- const context=vm.createContext({ClientNavigation:class{constructor(){this.provider="codex";}async refresh(){}acknowledgeList(){}},DesktopSessionsView:class{clear(){}},Workbench:class{open(){}reset(){}setThumbnails(){}relabel(){}},navigator:{userAgent:'test'},document:{addEventListener(){},documentElement:{},getElementById:id=>nodes.get(id),querySelector:selector=>selector==='.archive-filter'?archiveFilter:{disabled:false},querySelectorAll:()=>[],createElement:node},
+ const context=vm.createContext({ClientNavigation:class{constructor(){this.provider="codex";}async refresh(){}acknowledgeList(){}},DesktopSessionsView:class{clear(){}},Workbench:class{open(){}reset(){}setThumbnails(){}relabel(){}},navigator:{userAgent:'test'},document:{addEventListener(){},documentElement:{},getElementById:id=>nodes.get(id),querySelector:selector=>selector==='.archive-filter'?archiveFilter:{disabled:false},querySelectorAll:selector=>selector==='[data-notification-channel]'?channelButtons:[],createElement:node},
renderMarkdown(){},window:{addEventListener(){}},localStorage:storage(),sessionStorage:storage(),
location:{hash:'',pathname:'/',search:''},history:{replaceState(){}},setTimeout(){},clearTimeout(){},setInterval(){},
ChatTimeline:class{constructor(){this.abort=new AbortController();}async start(){}dispose(){this.abort.abort();}relabel(){}},
@@ -26,15 +27,21 @@ async function fixture(){
if(url.includes('/reconnect'))return response({ok:true});
if(url.startsWith('/api/sessions?')){listRequests.push(url);return listResponse(url);}
if(url==='/api/activity')return response({sessions:[]});
- if(url.startsWith('/api/notifications/pushplus')||url.includes('/rename?')){
+ if(url.startsWith('/api/notifications/channels/')||url.includes('/rename?')){
+ if(options.method!=='POST'&&nextGet){const handle=nextGet;nextGet=null;return handle(url);}
if(options.method==='POST'){
const body=JSON.parse(options.body);writes.push({url,body});
if(nextPost){const handle=nextPost;nextPost=null;return handle(body);}
if(url.includes('/rename?'))return response({title:body.title});
if(url.endsWith('/test'))return response({ok:true});
- saved.set(url,{pushplusEnabled:body.pushplusEnabled,hasPushplusToken:!!body.pushplusToken||!body.clearPushplusToken&&!!saved.get(url)?.hasPushplusToken});
+ if(url.endsWith('/common'))saved.set(url,body);
+ else {
+ const secret=url.endsWith('/bark')?'barkKey':url.endsWith('/ntfy')?'token':'pushplusToken';
+ const marker=url.endsWith('/bark')?'hasBarkKey':url.endsWith('/ntfy')?'hasToken':'hasPushplusToken';
+ saved.set(url,{...body,[secret]:undefined,[marker]:!!body[secret]||!body['clear'+secret[0].toUpperCase()+secret.slice(1)]&&!!saved.get(url)?.[marker]});
+ }
}
- return response(saved.get(url)||{pushplusEnabled:false,hasPushplusToken:false});
+ return response(saved.get(url)||{pushplusEnabled:false,hasPushplusToken:false,barkServer:'https://api.day.app',server:'https://ntfy.sh'});
}
assert.match(url,/\/notifications\?/);
if(options.method==='POST'){
@@ -47,7 +54,7 @@ async function fixture(){
for(const file of ['web/hosts/environment.js','web/shared/i18n.js','web/features/chat/list-sync.js','web/features/accounts/account.js','web/features/chat/modes.js','web/features/chat/attachments.js','web/features/chat/activity.js','web/features/chat/fast-mode.js','web/features/chat/message-actions.js','web/features/chat/permissions.js','web/shell/app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'..',file),'utf8'),context);
await new Promise(setImmediate);
const run=code=>vm.runInContext(code,context);
- return {nodes,writes,saved,run,html,response,archiveToggles,listRequests,setList:handle=>{listResponse=handle;},setPost:handle=>{nextPost=handle;},
+ return {nodes,channelButtons,writes,saved,run,html,response,archiveToggles,listRequests,setList:handle=>{listResponse=handle;},setPost:handle=>{nextPost=handle;},setGet:handle=>{nextGet=handle;},
async open(id='11111111-1111-4111-8111-111111111111',host='local'){
await run(`openChat(${JSON.stringify(id)},${JSON.stringify(host)})`);await new Promise(setImmediate);
},
@@ -124,16 +131,24 @@ test('notification controls have English translations and language changes prese
});
-test('PushPlus saves and tests settings without redisplaying its token',async()=>{
+test('third-party notification channels save independently, mask secrets and test the selected saved channel',async()=>{
const ui=await fixture();await ui.open();
await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-channels-dialog').open,true);
+ await ui.channelButtons[2].onclick();
assert.equal(ui.nodes.get('pushplus-dialog').open,true);
ui.nodes.get('pushplus-enabled').checked=true;ui.nodes.get('pushplus-token').value='secret';
await ui.nodes.get('pushplus-form').onsubmit({preventDefault(){}});
assert.deepEqual(ui.writes[0].body,{pushplusEnabled:true,pushplusToken:'secret',clearPushplusToken:false});
assert.equal(ui.nodes.get('pushplus-token').value,'');
assert.equal(ui.nodes.get('pushplus-token').placeholder,'已保存,留空保留');
- await ui.nodes.get('pushplus-test').onclick();assert.equal(ui.writes[1].url,'/api/notifications/pushplus/test');
+ await ui.nodes.get('pushplus-test').onclick();assert.equal(ui.writes[1].url,'/api/notifications/channels/pushplus/test');
+ ui.nodes.get('notification-channel-back').onclick();await ui.channelButtons[0].onclick();
+ assert.equal(ui.nodes.get('notification-server').value,'https://api.day.app');
+ ui.nodes.get('pushplus-enabled').checked=true;ui.nodes.get('pushplus-token').value='bark-secret';
+ await ui.nodes.get('pushplus-form').onsubmit({preventDefault(){}});
+ assert.deepEqual(ui.writes[2].body,{barkEnabled:true,barkKey:'bark-secret',clearBarkKey:false,barkServer:'https://api.day.app'});
+ await ui.nodes.get('pushplus-test').onclick();assert.equal(ui.writes[3].url,'/api/notifications/channels/bark/test');
ui.nodes.get('pushplus-token').value='retry-token';
ui.setPost(()=>ui.response({error:'Network failure'},400));
await ui.nodes.get('pushplus-form').onsubmit({preventDefault(){}});
@@ -141,6 +156,58 @@ test('PushPlus saves and tests settings without redisplaying its token',async()=
assert.equal(ui.nodes.get('pushplus-error').textContent,'Network failure');
});
+test('common notification controls save once outside provider detail',async()=>{
+ const ui=await fixture();await ui.nodes.get('pushplus-settings').onclick();
+ ui.nodes.get('notification-security-enabled').checked=true;
+ ui.nodes.get('notification-address-enabled').checked=false;
+ ui.nodes.get('notification-address-name').value='Home gateway';
+ ui.nodes.get('notification-include-title').checked=true;
+ ui.nodes.get('notification-click-base').value='https://bridge.example';
+ await ui.nodes.get('notification-common-save').onclick();
+ assert.deepEqual(ui.writes[0].body,{securityEnabled:true,addressEnabled:false,addressName:'Home gateway',includeTitle:true,clickBase:'https://bridge.example'});
+ assert.equal(ui.nodes.get('notification-channels-dialog').open,true);
+});
+
+test('provider list reports configured and enabled states',async()=>{
+ const ui=await fixture();
+ ui.saved.set('/api/notifications/channels/bark',{barkEnabled:true,hasBarkKey:true});
+ ui.saved.set('/api/notifications/channels/ntfy',{enabled:false,topic:'saved-topic'});
+ await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-status-bark').textContent,'已开启');
+ assert.equal(ui.nodes.get('notification-status-ntfy').textContent,'已关闭');
+ assert.equal(ui.nodes.get('notification-status-pushplus').textContent,'未配置');
+});
+
+test('common settings wait for a valid read and ignore late responses after close',async()=>{
+ const ui=await fixture();let finish;
+ ui.setGet(url=>url.endsWith('/common')?new Promise(resolve=>{finish=resolve;}):ui.response({}));
+ const opening=ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-common-save').disabled,true);
+ await ui.nodes.get('notification-common-save').onclick();
+ assert.equal(ui.writes.length,0);
+ ui.nodes.get('notification-channels-dialog').close();
+ finish(ui.response({securityEnabled:false,addressEnabled:false,addressName:'old',includeTitle:false,clickBase:''}));
+ await opening;
+ assert.equal(ui.nodes.get('notification-common-save').disabled,true);
+ ui.saved.set('/api/notifications/channels/common',{securityEnabled:true,addressEnabled:true,addressName:'new',includeTitle:true,clickBase:''});
+ await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-address-name').value,'new');
+ assert.equal(ui.nodes.get('notification-common-save').disabled,false);
+});
+
+test('failed common read blocks save and permits explicit retry',async()=>{
+ const ui=await fixture();
+ ui.setGet(()=>ui.response({error:'Read failed'},500));
+ await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-common-save').disabled,true);
+ assert.equal(ui.nodes.get('notification-common-retry').hidden,false);
+ assert.equal(ui.nodes.get('notification-common-error').textContent,'Read failed');
+ await ui.nodes.get('notification-common-save').onclick();
+ assert.equal(ui.writes.length,0);
+ await ui.nodes.get('notification-common-retry').onclick();
+ assert.equal(ui.nodes.get('notification-common-save').disabled,false);
+});
+
test('rename stays scoped to its original host and cannot overwrite a newly opened chat',async()=>{
const ui=await fixture();await ui.open(undefined,'remote:test');
ui.nodes.get('chat-title').textContent='Old';ui.nodes.get('rename-chat').onclick();
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 789792be..53f977ac 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -30,10 +30,13 @@ def auth(self):
def test_permanent_login_survives_restart_without_storing_bearer(self):
auth = self.auth()
+ self.assertEqual(auth.cookie_name, self.auth().cookie_name)
+ self.assertRegex(auth.cookie_name, r'^codex_mobile_session_[0-9a-f]{24}$')
token, session = auth.new_session('192.0.2.7', 'iPhone Safari')
self.assertEqual(session['expires'], 0)
raw = (self.directory / 'auth-sessions.json').read_text()
self.assertNotIn(token, raw)
+ self.assertIn('cookieName', raw)
with patch('bridge.features.auth.auth.time.time', return_value=session['created'] + 50 * 365 * 86400):
restarted = self.auth()
self.assertEqual(restarted.get(token)['csrf'], session['csrf'])
diff --git a/tests/test_bridge.py b/tests/test_bridge.py
index 4917d7e5..c9a13ecb 100644
--- a/tests/test_bridge.py
+++ b/tests/test_bridge.py
@@ -863,6 +863,39 @@ def test_pushplus_configuration_requires_login_and_csrf_and_hides_token(self):
send.assert_called_once()
self.assertEqual(self.request('POST', path, {'server':'https://other.example'}, headers)[0], 400)
+ def test_third_party_channels_are_scoped_and_secrets_remain_masked(self):
+ from bridge.features.notifications.channels import Notifications, settings
+ from unittest.mock import patch
+ with tempfile.TemporaryDirectory(dir=ROOT/'.tmp') as directory:
+ self.server.notifications = Notifications(None, directory)
+ headers = self.login()
+ bark = '/api/notifications/channels/bark'
+ ntfy = '/api/notifications/channels/ntfy'
+ common = '/api/notifications/channels/common'
+ self.assertEqual(self.request('GET', bark)[0], 401)
+ self.assertEqual(self.request('POST', bark, {'barkEnabled': True}, {'Cookie': headers['Cookie']})[0], 403)
+ body = {'barkEnabled': True, 'barkServer': 'https://api.day.app', 'barkKey': 'private-bark'}
+ self.assertEqual(self.request('POST', bark, body, headers)[2]['hasBarkKey'], True)
+ self.assertEqual(self.request('POST', ntfy, {'enabled': True, 'topic': 'test-topic'}, headers)[0], 200)
+ self.assertEqual(self.request('POST', common, {'includeTitle': True, 'securityEnabled': False}, headers)[0], 200)
+ self.assertEqual(settings(directory)['barkKey'], 'private-bark')
+ self.assertTrue(settings(directory)['includeTitle'])
+ self.assertFalse(settings(directory)['securityEnabled'])
+ self.assertNotIn('private-bark', json.dumps(self.request('GET', bark, headers=headers)))
+ self.assertEqual(self.request('POST', ntfy, {'barkKey': 'leak'}, headers)[0], 400)
+ self.assertEqual(self.request('POST', common, {'pushplusToken': 'leak'}, headers)[0], 400)
+ self.assertEqual(self.request('POST', bark, {'clearBarkKey': 'false'}, headers)[0], 400)
+ self.assertEqual(self.request('POST', ntfy, {'clearToken': 1}, headers)[0], 400)
+ self.assertEqual(settings(directory)['barkKey'], 'private-bark')
+ with patch('bridge.api.httpd.publish_bark') as send_bark, patch('bridge.api.httpd.publish') as send_ntfy:
+ self.assertEqual(self.request('POST', bark + '/test', {}, headers)[0], 200)
+ send_bark.assert_called_once()
+ send_ntfy.assert_not_called()
+ self.assertEqual(self.request('POST', ntfy + '/test', {}, headers)[0], 200)
+ send_ntfy.assert_called_once()
+ self.assertEqual(self.request('POST', bark, {'barkServer': 'https://other.example', 'barkEnabled': False}, headers)[2]['hasBarkKey'], False)
+ self.assertEqual(settings(directory)['barkKey'], '')
+
def test_rename_route_requires_csrf_and_routes_to_selected_host(self):
from unittest.mock import Mock
headers = self.login()
diff --git a/tests/test_claude_controls.py b/tests/test_claude_controls.py
index 3e76811f..ca453631 100644
--- a/tests/test_claude_controls.py
+++ b/tests/test_claude_controls.py
@@ -76,6 +76,11 @@ def test_reconnect_rejects_disabled_stopped_ambiguous_and_unprepared_clients(sel
with self.assertRaisesRegex(ValueError, '电脑端'): self.manager.reconnect_claude()
self.claude.connect.assert_not_called(); self.fixture.launch.assert_not_called()
+ def test_reconnect_can_retry_after_a_reported_other_gateway_conflict(self):
+ self.claude.status.return_value = {'connected': False, 'setupState': 'owner-conflict'}
+ self.manager.reconnect_claude()
+ self.claude.reconnect.assert_called_once_with(); self.claude.connect.assert_not_called()
+
def test_connected_reconnect_is_idempotent_even_when_tasks_are_busy(self):
self.claude.status.return_value = {'connected': True}
self.claude.call.side_effect = lambda *_: {'complete': True, 'sessions': [
diff --git a/tests/test_claude_native_helper.py b/tests/test_claude_native_helper.py
index c5398e97..e71da63a 100644
--- a/tests/test_claude_native_helper.py
+++ b/tests/test_claude_native_helper.py
@@ -64,6 +64,83 @@ def test_observed_console_selectors_submission_and_window_guards(self):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)['setupState'], 'ready', result.stdout)
+ def test_product_verify_requires_one_complete_read_and_bounded_navigation(self):
+ source = (ROOT/'bridge/platforms/macos/claude-helper.swift').read_text()
+ actual = source[source.index(' func verify(_ prompt: AXUIElement'):source.index(' func submit(_ prompt: AXUIElement')]
+ fixture = '''import Foundation
+import CoreFoundation
+struct SetupError: Error { let state: String; let reason: String }
+func fail(_ state: String, _ reason: String) throws -> Never { throw SetupError(state: state, reason: reason) }
+struct AXUIElement {}
+struct AXError: Equatable { let code: Int; static let success = AXError(code: 0); static let failure = AXError(code: 1) }
+enum FakeAXValueType { case cfRange }
+struct FakeAXValue { let range: CFRange }
+func AXValueCreate(_ type: FakeAXValueType, _ range: inout CFRange) -> FakeAXValue? { FakeAXValue(range: range) }
+let kAXValueAttribute = "AXValue", kAXSelectedTextRangeAttribute = "AXSelectedTextRange"
+struct FakeFlags { static let maskCommand = FakeFlags() }
+final class Scenario {
+ var reads: [String?], readIndex = 0, checks = 0, stopAt: Int?, stopState: String, settable: Bool
+ var keys = [Int](), positions = [Int]()
+ init(_ reads: [String?], settable: Bool = true, stopAt: Int? = nil, stopState: String = "cancelled") {
+ self.reads = reads; self.settable = settable; self.stopAt = stopAt; self.stopState = stopState
+ }
+}
+var scenario = Scenario([])
+func attribute(_ element: AXUIElement, _ name: String) -> Any? {
+ let index = min(scenario.readIndex, scenario.reads.count - 1)
+ scenario.readIndex += 1
+ return scenario.reads[index]
+}
+func AXUIElementIsAttributeSettable(_ element: AXUIElement, _ name: CFString, _ settable: inout DarwinBoolean) -> AXError {
+ settable = DarwinBoolean(scenario.settable)
+ return .success
+}
+func AXUIElementSetAttributeValue(_ element: AXUIElement, _ name: CFString, _ value: FakeAXValue) -> AXError {
+ scenario.positions.append(value.range.location)
+ return .success
+}
+final class Connector {
+ func check(_ prompt: AXUIElement) throws {
+ scenario.checks += 1
+ if scenario.stopAt == scenario.checks { try fail(scenario.stopState, "stopped") }
+ }
+ func key(_ code: Int, flags: FakeFlags = FakeFlags(), prompt: AXUIElement) throws {
+ try check(prompt)
+ scenario.keys.append(code)
+ }
+''' + actual + '''}
+let connector = Connector(), prompt = AXUIElement(), good = "abcdefghij"
+func run(_ name: String, _ reads: [String?], settable: Bool = true, stopAt: Int? = nil,
+ stopState: String = "cancelled", pass: Bool, moves: Int, selections: Int? = nil, restored: Bool = false) {
+ scenario = Scenario(reads, settable: settable, stopAt: stopAt, stopState: stopState)
+ var succeeded = false, stopped = false
+ do { try connector.verify(prompt, good); succeeded = true }
+ catch let error as SetupError { stopped = error.state == stopState }
+ catch { fatalError("unexpected error in " + name) }
+ precondition(succeeded == pass, name)
+ if stopAt != nil { precondition(stopped && scenario.keys.isEmpty, name) }
+ precondition(scenario.keys.filter { $0 == 124 }.count == moves, name)
+ precondition(scenario.keys.contains(125) == restored, name)
+ precondition(scenario.positions.count == (selections ?? moves), name)
+}
+run("exact", [good], pass: true, moves: 0)
+run("revealed", Array(repeating: "abc", count: 40) + [good], pass: true, moves: 0, restored: true)
+run("midpoint", Array(repeating: "abc", count: 42) + ["abcdef", good], pass: true, moves: 1, restored: true)
+run("same-length-middle-change", ["abcxefghij"], pass: false, moves: 3)
+run("truncated", ["abcdefghi"], pass: false, moves: 3)
+run("unreadable", [nil], pass: false, moves: 0)
+run("not-settable", ["abcdefghi"], settable: false, pass: false, moves: 0)
+run("cancelled", ["abcdefghi"], stopAt: 1, pass: false, moves: 0)
+run("focus-changed", ["abcdefghi"], stopAt: 44, stopState: "needs-attention", pass: false, moves: 0, selections: 1)
+print("9 product verify flow fixtures passed")
+'''
+ with tempfile.TemporaryDirectory(dir=ROOT/'.tmp') as folder:
+ folder = Path(folder); script = folder/'verify.swift'; script.write_text(fixture)
+ result = subprocess.run(['swift', '-module-cache-path', str(folder/'modules'), str(script)],
+ capture_output=True, text=True, timeout=90)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+ self.assertIn('9 product verify flow fixtures passed', result.stdout)
+
@unittest.skipUnless(sys.platform == 'win32', 'Windows native helper required')
class WindowsNativeWindowSelectors(unittest.TestCase):
diff --git a/tests/test_claude_session.py b/tests/test_claude_session.py
index 16c11cf2..8802d48a 100644
--- a/tests/test_claude_session.py
+++ b/tests/test_claude_session.py
@@ -32,6 +32,22 @@ def setUp(self):
def locked(self):
self.snapshot.return_value = {'state': 'locked', 'interactive': False, 'reason': 'Windows 已锁定'}
+ def test_windows_owner_conflict_receipt_has_actionable_state(self):
+ process = Mock(returncode=0)
+ process.communicate.return_value = ('OWNER_CONFLICT', '')
+ with patch.object(claude_setup, 'helper_path', return_value=Path(__file__)), \
+ patch.object(claude_setup.subprocess, 'Popen', return_value=process) as popen:
+ result = claude_setup.native_action('inspect-error', pid=42, script='attempt-marker')
+ self.assertEqual(result['setupState'], 'owner-conflict')
+ self.assertIn('旧网关', result['reason'])
+ self.assertEqual(popen.call_args.args[0][-1], 'attempt-marker')
+
+ def test_macos_error_inspection_receives_only_the_current_attempt_marker(self):
+ from bridge.platforms.macos.claude import helper_command
+ command, _ = helper_command(Path('helper'), 'inspect-error', 42, Path('Claude'),
+ 'attempt-marker', None)
+ self.assertEqual(command[-1], 'attempt-marker')
+
def test_locked_check_and_connect_do_not_start_helper(self):
self.locked()
with patch('bridge.platforms.windows.claude.subprocess') as processes:
diff --git a/tests/test_claude_setup.py b/tests/test_claude_setup.py
index 4f4f4899..05de29cb 100644
--- a/tests/test_claude_setup.py
+++ b/tests/test_claude_setup.py
@@ -178,7 +178,8 @@ def legacy_open(path, mode='r', buffering=-1, encoding=None, errors=None, newlin
prepared = adapter.prepare()
source = (ROOT/'bridge/clients/claude/connector.js').read_text(encoding='utf-8')
config = {'transport': 'file', 'cwd': str(adapter.directory), 'token': adapter.desktop.token,
- 'generation': adapter.desktop.generation, 'reconnect': True, 'connectorRevision': 5,
+ 'generation': adapter.desktop.generation, 'reconnect': True,
+ 'inspectionMarker': prepared['inspectionMarker'], 'connectorRevision': 5,
'request': 'request.json', 'response': 'response.json'}
self.assertEqual(Path(prepared['scriptPath']).read_text(encoding='utf-8'),
source.replace('__BRIDGE_CONFIG__', json.dumps(config)))
@@ -498,7 +499,7 @@ def developer(path):
self.adapter._connect_native(False, self.cancel_path)
profiles.assert_not_called()
- def run_injection(self, heartbeat, trust=False):
+ def run_injection(self, heartbeat, trust=False, conflict=False):
self.adapter.desktop = self.idle_desktop(connected=False)
actions = []
def native(action, **kwargs):
@@ -510,12 +511,14 @@ def native(action, **kwargs):
if action == 'close-devtools':
return {'setupState': 'connected'}
if action == 'inspect-error':
+ if conflict:
+ return {'setupState': 'owner-conflict', 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude'}
return {'setupState': 'needs-trust' if trust else 'failed', 'reason': 'trust required'}
return {'setupState': 'ready'}
with patch('bridge.clients.claude.adapter.native_action', side_effect=native), \
patch('bridge.clients.claude.adapter.running_app', return_value=42), \
patch('bridge.clients.claude.adapter.developer_mode_enabled', return_value=True), \
- patch('bridge.clients.claude.adapter.time.monotonic', side_effect=[0, 0 if heartbeat else 19]), \
+ patch('bridge.clients.claude.adapter.time.monotonic', side_effect=[0, 0, 3 if conflict else 0 if heartbeat else 19, 3]), \
patch.object(self.adapter, 'prepare', return_value={'consolePath': str(self.root/'script')}), \
patch.object(self.adapter.setup_cancel, 'wait', return_value=False), \
patch.object(self.adapter, '_setup_state', wraps=self.adapter._setup_state) as state:
@@ -534,6 +537,12 @@ def test_trust_failure_reports_native_user_action(self):
self.assertNotIn('connected', states)
self.assertEqual(self.adapter.status()['setupState'], 'needs-trust')
+ def test_owner_conflict_reports_without_waiting_for_full_connection_timeout(self):
+ actions, states = self.run_injection(False, conflict=True)
+ self.assertEqual(actions, ['check', 'connect', 'inspect-error'])
+ self.assertIn('owner-conflict', states)
+ self.assertEqual(self.adapter.status()['setupState'], 'owner-conflict')
+
def test_only_verified_heartbeat_allows_connected_and_console_cleanup(self):
actions, states = self.run_injection(True)
self.assertEqual(actions, ['check', 'connect', 'close-devtools'])
@@ -786,6 +795,13 @@ def test_reset_preserves_token_but_rejects_previous_signed_generation(self):
self.assertNotIn('\n', source)
self.assertIn(self.adapter.desktop.generation, source)
+ def test_each_console_injection_has_a_fresh_error_inspection_marker(self):
+ self.addCleanup(self.adapter.close)
+ first = self.adapter.prepare()
+ second = self.adapter.prepare()
+ self.assertNotEqual(first['inspectionMarker'], second['inspectionMarker'])
+ self.assertIn(second['inspectionMarker'], Path(second['consolePath']).read_text(encoding='utf-8'))
+
def test_transformed_console_script_preserves_file_protocol_behavior(self):
source = (ROOT/'bridge/clients/claude/connector.js').read_text(encoding='utf-8')
config = {'cwd': 'D:/fixture', 'request': 'request.json', 'response': 'response.json',
diff --git a/tests/test_login_security.py b/tests/test_login_security.py
index 65eb0de6..aae1c9c0 100644
--- a/tests/test_login_security.py
+++ b/tests/test_login_security.py
@@ -199,12 +199,46 @@ def test_cookie_modes_and_auth_refresh(self):
self.assertIn('HttpOnly', headers['Set-Cookie'])
self.assertEqual('Max-Age=' in headers['Set-Cookie'], remember)
cookie = headers['Set-Cookie'].split(';')[0]
+ self.assertTrue(cookie.startswith(self.server.auth.cookie_name + '='))
_, refreshed, auth = self.request('/api/auth', headers={'Cookie': cookie})
self.assertTrue(auth['authenticated'])
self.assertEqual('Max-Age=' in refreshed['Set-Cookie'], remember)
if remember:
self.assertIn('Max-Age=604800', headers['Set-Cookie'])
+ def test_legacy_cookie_migrates_only_if_owned_by_this_gateway(self):
+ token, _ = self.server.auth.new_session('127.0.0.1')
+ legacy = 'codex_mobile_session=' + token
+ _, headers, auth = self.request('/api/auth', headers={'Cookie': legacy})
+ self.assertTrue(auth['authenticated'])
+ self.assertEqual(auth['cookieName'], self.server.auth.cookie_name)
+ self.assertTrue(headers['Set-Cookie'].startswith(self.server.auth.cookie_name + '='))
+ _, _, foreign = self.request('/api/auth', headers={'Cookie': 'codex_mobile_session=foreign'})
+ self.assertFalse(foreign['authenticated'])
+ mixed = self.server.auth.cookie_name + '=invalid; ' + legacy
+ self.assertFalse(self.request('/api/auth', headers={'Cookie': mixed})[2]['authenticated'])
+ self.assertTrue(self.request('/api/auth', headers={'Cookie': legacy})[2]['authenticated'])
+
+ def test_two_same_host_gateways_keep_separate_cookies_and_logout(self):
+ first = self.server.auth
+ with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as directory:
+ second = Auth(CONFIG, directory)
+ self.assertNotEqual(first.cookie_name, second.cookie_name)
+ one, _ = first.new_session('127.0.0.1')
+ two, _ = second.new_session('127.0.0.1')
+ jar = f'{first.cookie_name}={one}; {second.cookie_name}={two}'
+ _, _, auth_one = self.request('/api/auth', headers={'Cookie': jar})
+ self.assertTrue(auth_one['authenticated'])
+ self.server.auth = second
+ _, _, auth_two = self.request('/api/auth', headers={'Cookie': jar})
+ self.assertTrue(auth_two['authenticated'])
+ self.server.auth = first
+ _, headers, _ = self.request('/api/logout', {}, {'Cookie': jar, 'X-CSRF-Token': auth_one['csrf']})
+ self.assertTrue(headers['Set-Cookie'].startswith(first.cookie_name + '=;'))
+ self.assertFalse(self.request('/api/auth', headers={'Cookie': jar})[2]['authenticated'])
+ self.server.auth = second
+ self.assertTrue(self.request('/api/auth', headers={'Cookie': jar})[2]['authenticated'])
+
def test_mobile_http_stays_signed_in_and_success_clears_attempts(self):
headers = {'User-Agent': 'BridgeMobile/0.1-Android'}
self.assertFalse(self.request('/api/auth', headers=headers)[2]['authenticated'])
diff --git a/tests/test_pty_terminal.py b/tests/test_pty_terminal.py
index 5cc024f4..72d9ea05 100644
--- a/tests/test_pty_terminal.py
+++ b/tests/test_pty_terminal.py
@@ -86,9 +86,11 @@ def test_input_retry_is_exactly_once_and_conflicts_rejected(self):
self.assertEqual((self.root/'exactly-once').read_text(),'x')
with self.assertRaises(ValueError):self.send(s,'echo different\r',identifier)
def test_close_kills_foreground_process(self):
- s=self.session(); self.send(s,'sleep 90 & echo $! > child.pid; wait\r')
+ # The child reports readiness itself: a parent-written PID can race
+ # zsh's forked child before it has installed its signal disposition.
+ s=self.session(); self.send(s,"sh -c 'echo $$ > child.pid; exec sleep 90' & wait\r")
deadline=time.monotonic()+5
- while not (self.root/'child.pid').exists() and time.monotonic()
this.update(true);
const resume=()=>{if(!document.hidden)this.update();};
- document.addEventListener('visibilitychange',resume);window.addEventListener('focus',resume);
+ if(automatic){document.addEventListener('visibilitychange',resume);window.addEventListener('focus',resume);}
root.addEventListener('touchstart',event=>{
if(this.start)this.endPull(true);
this.start=!this.busy&&!document.hidden&&visible()&&ready()&&root.scrollTop<=0&&event.touches.length===1?{x:event.touches[0].clientX,y:event.touches[0].clientY}:null;this.distance=0;
@@ -26,7 +26,7 @@ class ListSync {
},{passive:false});
root.addEventListener('touchend',()=>this.endPull());
root.addEventListener('touchcancel',()=>this.endPull(true));
- this.schedule();
+ if(automatic)this.schedule();
}
setPull(distance,dragging=false){this.root.classList.toggle('list-pulling',dragging);this.root.style.setProperty('--list-pull',distance+'px');}
show(text,pull=0){
@@ -44,16 +44,23 @@ class ListSync {
if(this.frame)cancelAnimationFrame(this.frame);this.frame=0;
if(refresh)this.update(true);else{this.setPull(0);this.hide();}
}
+ reset(){this.revision++;this.busy=false;this.automaticFailures=0;this.failureVisible=false;this.recoveredWhileBusy=false;this.endPull(true);this.setPull(0);clearTimeout(this.hideTimer);clearTimeout(this.dismissTimer);this.status.classList.remove('list-sync-visible');this.status.hidden=true;}
+ acknowledgeSuccess(){this.automaticFailures=0;if(this.failureVisible){this.failureVisible=false;if(this.busy)this.recoveredWhileBusy=true;else this.hide();}}
+ reportFailure(error,explicit=false){
+ if(error?.status===401){this.failureVisible=false;this.hide();return;}
+ this.automaticFailures++;
+ if(explicit||this.automaticFailures>=3||!this.root.children?.length){this.failureVisible=true;this.show('列表暂未更新,点击重试');}
+ }
schedule(){clearTimeout(this.timer);this.timer=setTimeout(async()=>{await this.update();this.schedule();},15000);}
async update(explicit=false){
if(this.start||this.busy)return;
if(document.hidden||!this.visible()||!this.ready()||(!explicit&&this.root.scrollTop>24)){
if(explicit){this.setPull(0);this.hide();}return;
}
- this.busy=true;
+ this.busy=true;this.recoveredWhileBusy=false;const revision=this.revision;
if(explicit){this.setPull(32);this.show('正在刷新聊天列表…',32);}
- try{await this.refresh();if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}}
- catch(e){this.setPull(0);this.show('列表暂未更新,点击重试');}
- finally{this.busy=false;}
+ try{const updated=await this.refresh();if(revision!==this.revision)return;if(updated===false){this.setPull(0);this.hide();return;}const hadFailure=this.failureVisible;this.acknowledgeSuccess();if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}else if(hadFailure||this.recoveredWhileBusy)this.hide();}
+ catch(e){if(revision===this.revision){this.setPull(0);this.reportFailure(e,explicit);}}
+ finally{if(revision===this.revision){this.busy=false;this.recoveredWhileBusy=false;}}
}
}
diff --git a/web/features/clients/client-lifecycle.js b/web/features/clients/client-lifecycle.js
index 7bd6a5bf..bba91731 100644
--- a/web/features/clients/client-lifecycle.js
+++ b/web/features/clients/client-lifecycle.js
@@ -14,13 +14,14 @@ const ClientLifecycle=(()=>{
(stage==='starting'?'正在启动应用…':'正在连接,请稍候…');pending=true;}
else{
label=({'needs-unlock':'电脑已锁定','needs-desktop':'电脑桌面暂不可用'})[setup]||({'error':'连接失败','timeout':'连接超时','needs-initialization':'需要初始化连接'})[stage]
- ||({'unsupported':'暂不支持自动接入','needs-initialization':'需要初始化连接','needs-first-launch':'需要完成首次设置','restart-required':'等待重启','needs-permission':'等待授权','needs-developer-mode':'等待开发者模式确认','needs-trust':'等待目录授权','cancelled':'已取消连接','failed':'连接失败','needs-retry':'连接失败'})[setup];
+ ||({'unsupported':'暂不支持自动接入','needs-initialization':'需要初始化连接','needs-first-launch':'需要完成首次设置','restart-required':'等待重启','needs-permission':'等待授权','needs-developer-mode':'等待开发者模式确认','needs-trust':'等待目录授权','owner-conflict':'连接被其他网关占用','cancelled':'已取消连接','failed':'连接失败','needs-retry':'连接失败'})[setup];
action=!!label;
label=label||(client.installed===false?'未安装':client.running===true?'应用运行中,尚未连接':client.running===false?'应用未运行':'尚未连接');
}
const canInitialize=client.id==='claude'&&client.enabled&&client.installed!==false&&!client.connected&&!pending&&(['needs-initialization','needs-retry','needs-developer-mode','needs-trust','needs-permission','needs-unlock','needs-desktop','cancelled','failed'].includes(setup)||stage==='needs-initialization');
- const retryable=client.retryable===true&&client.enabled&&!client.connected&&!pending&&!canInitialize&&!['recovery-required','restart-required'].includes(setup);
- return {label,reason,waiting:pending,action:action||setup==='recovery-required',retryable:!!retryable,canInitialize:!!canInitialize,error:!client.connected&&['error','timeout'].includes(stage)||!client.connected&&setup==='failed'};
+ const canReconnect=client.id==='claude'&&client.canReconnect===true&&client.reconnectSupported===true&&!client.connected&&!pending&&!['recovery-required','restart-required'].includes(setup);
+ const retryable=client.retryable===true&&client.enabled&&!client.connected&&!pending&&!canInitialize&&!canReconnect&&!['recovery-required','restart-required'].includes(setup);
+ return {label,reason,waiting:pending,action:action||setup==='recovery-required',retryable:!!retryable,canReconnect:!!canReconnect,canInitialize:!!canInitialize,error:!client.connected&&['error','timeout'].includes(stage)||!client.connected&&setup==='failed'};
}
function request(operation,{timeout=45000,uncertain=true,message='操作结果尚未确认,请刷新状态后再重试。'}={}){
const controller=typeof AbortController!=='undefined'?new AbortController():null;let timer;
diff --git a/web/features/clients/client-navigation-actions.css b/web/features/clients/client-navigation-actions.css
index 3b40f626..0ec35d76 100644
--- a/web/features/clients/client-navigation-actions.css
+++ b/web/features/clients/client-navigation-actions.css
@@ -7,3 +7,5 @@
#computers-button{text-decoration:none;cursor:pointer;color:var(--accent)}
.ds-messages article{border:0}.client-functions #computers-button{color:var(--accent)}
+
+.settings-action-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,.7fr) minmax(0,.9fr);gap:6px;margin:4px 0 16px}.settings-action-row.two-actions{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-action-row.one-action{grid-template-columns:minmax(0,1fr)}.settings-action-row button{min-width:0;min-height:40px;padding:8px 4px;border:1px solid var(--line);border-radius:8px;background:var(--surface);font-size:12px;line-height:1.25;white-space:nowrap}.settings-action-row button[hidden]{display:none}@media(max-width:360px){#appearance-dialog{padding-inline:16px}.settings-action-row{gap:4px}.settings-action-row button{padding-inline:2px}}
diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css
index 4c3efc78..437a30ae 100644
--- a/web/features/clients/client-navigation.css
+++ b/web/features/clients/client-navigation.css
@@ -1,13 +1,14 @@
-.gateway-authenticated>.phone-header{display:none}.list-heading{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto auto;gap:6px;min-height:60px}.list-heading h2{display:flex;align-items:center;gap:7px;font-size:17px;white-space:nowrap}.client-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px}.client-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:23px;height:23px;font-size:20px;line-height:1}.client-icon img{width:100%;height:100%;object-fit:contain}.connected-computer{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;padding:4px 0;color:var(--muted);min-width:0}.connected-computer[hidden]{display:block!important;visibility:hidden}.list-heading #new-chat{font-size:12px;white-space:nowrap;gap:3px;padding:6px 2px}.list-heading .list-more{font-size:13px;padding:6px 0}.client-navigation{margin-top:auto;flex:none;min-width:0}.client-functions{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:center;gap:0;padding:7px 0;min-height:48px}.client-functions>*{min-width:0;flex:1!important;font-size:11px!important;padding:9px 2px!important;white-space:nowrap}.client-functions #computers-button{margin:0!important;justify-self:end;text-align:right}.navigation-hidden{display:none!important}.client-switch{display:flex;gap:4px;border-top:1px solid var(--line);padding:6px 0 max(7px,env(safe-area-inset-bottom))}.client-switch button{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 2px;border:0;background:transparent;border-radius:8px;font-size:11px;color:var(--muted)}.client-switch button.selected{background:var(--surface);color:var(--text);font-weight:600}.client-management-row{display:flex!important;align-items:center;gap:12px;padding:15px 0;border-bottom:1px solid var(--line)}.client-management-row>span:nth-child(2){flex:1}.client-management-row small{display:block;color:var(--muted);font-size:11px;margin-top:6px}.chat-head>.client-icon{margin-right:0}.chat-title-button,#chat-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.no-client #sessions,.no-client #search,.no-client .list-filters{display:none}.no-client-message{padding:30px 5px}.ds-list{display:flex;flex-direction:column;padding:0 16px;overflow:hidden}.ds-list>.sessions{flex:1;min-height:0;overflow:auto}.ds-list>input{flex:none}.ds-list>p{margin:10px 0;font-size:12px}.ds-head>strong{white-space:nowrap;text-overflow:ellipsis;min-width:0}.ds-head>.client-icon{flex:none}.ds-conversation .wb-main-pane>.ds-messages{flex:1}.ds-conversation.workbench-active:not(.wb-split) .wb-main-pane{display:none}.ds-head .ds-back{display:block!important}.ds-conversation .wb-main-label{white-space:nowrap}.ds-conversation .wb-layout{flex:1}
+.gateway-authenticated>.phone-header{display:none}.list-heading{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto auto;gap:6px;min-height:60px}.list-heading h2{display:flex;align-items:center;gap:7px;font-size:17px;white-space:nowrap}.client-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px}.client-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:23px;height:23px;font-size:20px;line-height:1}.client-icon img{width:100%;height:100%;object-fit:contain}.connected-computer{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;padding:4px 0;color:var(--muted);min-width:0}.connected-computer[hidden]{display:block!important;visibility:hidden}.list-heading #new-chat{font-size:12px;white-space:nowrap;gap:3px;padding:6px 2px}.list-heading .list-more{font-size:13px;padding:6px 0}.client-navigation{margin-top:auto;flex:none;min-width:0}.client-functions{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:center;gap:0;padding:7px 0;min-height:48px}.client-functions>*{min-width:0;flex:1!important;font-size:11px!important;padding:9px 2px!important;white-space:nowrap}.client-functions #computers-button{margin:0!important;justify-self:end;text-align:right}.navigation-hidden{display:none!important}.client-switch{display:flex;gap:4px;border-top:1px solid var(--line);padding:6px 0 max(7px,env(safe-area-inset-bottom))}.client-switch button{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 2px;border:0;background:transparent;border-radius:8px;font-size:11px;color:var(--muted)}.client-switch button.selected{background:var(--surface);color:var(--text);font-weight:600}.client-management-row{display:flex!important;align-items:center;gap:12px;padding:15px 0;border-bottom:1px solid var(--line)}.client-management-row>span:nth-child(2){flex:1}.client-management-row small{display:block;color:var(--muted);font-size:11px;margin-top:6px}.chat-head>.client-icon{margin-right:0}.chat-title-button,#chat-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.no-client #sessions,.no-client #search,.no-client .list-filters{display:none}.no-client-message{padding:30px 5px}.ds-list{display:flex;flex-direction:column;padding:0 16px;overflow:hidden;container-type:inline-size}.ds-list>.session-list-region>.sessions{overflow:auto}.ds-list>input{flex:none}.ds-list>p{margin:10px 0;font-size:12px}.ds-head>strong{white-space:nowrap;text-overflow:ellipsis;min-width:0}.ds-head>.client-icon{flex:none}.ds-conversation .wb-main-pane>.ds-messages{flex:1}.ds-conversation.workbench-active:not(.wb-split) .wb-main-pane{display:none}.ds-head .ds-back{display:block!important}.ds-conversation .wb-main-label{white-space:nowrap}.ds-conversation .wb-layout{flex:1}
/* Shared by all providers; dimensions follow the list pane, not the host OS. */
-.chat-list-toolbar{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;flex:none;margin:4px 0 8px;min-width:0}
-.chat-list-toolbar>.search{width:100%;min-width:0;height:36px;padding:7px 8px;margin:0}
-.chat-list-toolbar>.list-filters{display:contents}
-.chat-list-toolbar .list-mode{padding:0;min-width:0}
+.chat-list-toolbar{display:flex;flex-wrap:wrap;gap:3px;align-items:center;flex:none;margin:4px 0 8px;min-width:0}
+.chat-list-toolbar>.search{flex:1 1 60px;width:100%;min-width:60px;height:36px;padding:7px 8px;margin:0}
+.chat-list-toolbar>.list-filters{display:flex;flex:none;flex-wrap:wrap;gap:3px;max-width:100%;min-width:0}
+.chat-list-toolbar .list-mode{flex:none;padding:0;min-width:0}
.chat-list-toolbar .list-mode>span{display:none}
-.chat-list-toolbar .list-mode select{height:36px;padding:6px 3px;width:auto;max-width:108px;font-size:12px}
-.chat-list-toolbar .archive{display:flex;align-items:center;gap:3px;font-size:11px;min-height:36px}
+.chat-list-toolbar .list-mode select{height:36px;padding:6px 3px;font-size:12px}
+.chat-list-toolbar .archive{display:flex;align-items:center;gap:3px;flex:none;font-size:11px;min-height:36px}
.chat-list-toolbar .archive input{width:14px;height:14px;flex:none;margin:0}
+.ds-list .chat-list-toolbar .ds-surface{flex:none;height:36px;margin:0;padding:6px 3px}
.client-switch-dock{display:flex;align-items:center;border-top:1px solid var(--line);padding-bottom:max(4px,env(safe-area-inset-bottom))}
.client-switch-dock .client-switch{flex:1;min-width:0;border:0;padding:4px 0}
.client-functions>[hidden],.client-switch-dock[hidden]{display:none!important}
diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js
index cfc94ff7..4468c736 100644
--- a/web/features/clients/client-navigation.js
+++ b/web/features/clients/client-navigation.js
@@ -1,4 +1,26 @@
'use strict';
+function bindChatListToolbar(toolbar){
+ const selects=[...toolbar.querySelectorAll('select')];
+ const measure=document.createElement('span');
+ measure.style.cssText='position:absolute;visibility:hidden;white-space:pre;pointer-events:none';
+ document.body.append(measure);
+ const update=()=>{
+ for(const select of selects){
+ const style=getComputedStyle(select);
+ measure.style.font=style.font;
+ measure.style.letterSpacing=style.letterSpacing;
+ measure.textContent=select.selectedOptions[0]?.textContent||'';
+ // Reserve the native arrow and its spacing; only the selected label determines width.
+ select.style.width=Math.ceil(measure.getBoundingClientRect().width+30)+'px';
+ }
+ };
+ const later=()=>requestAnimationFrame(update);
+ toolbar.addEventListener('change',update);
+ document.addEventListener('bridge-language',later);
+ window.addEventListener('resize',update);
+ if(typeof ResizeObserver!=='undefined')new ResizeObserver(update).observe(toolbar);
+ later();
+}
class ClientNavigation {
constructor({view,request,notify}) {
Object.assign(this,{view,request,notify});this.clients=[];this.revision=0;this.pending=new Set();this.provider=localStorage.getItem('bridge-client')||'codex';const requested=new URLSearchParams(location.search).get('client');if(['codex','claude','deepseek'].includes(requested))this.provider=requested;
@@ -10,7 +32,7 @@ class ClientNavigation {
this.footer.querySelector(':scope > span')?.remove();
const make=(label,action)=>{const b=document.createElement('button');b.type='button';b.className='plain';b.dataset.i18n=label;b.textContent=BridgeI18n.t(label);b.onclick=action;return b;};
this.manage=make('应用管理',()=>this.openManager());this.manage.id='clients-button';
- this.home=document.createElement('a');this.home.className='plain';this.home.dataset.i18n='返回电脑列表';this.home.textContent=BridgeI18n.t('返回电脑列表');this.home.href='#computers';this.home.id='computers-button';this.home.onclick=event=>{if(BridgeHost.hasNativeLayout()){this.home.href='codexbridge://home';return;}event.preventDefault();this.homeDialog();};
+ this.home=document.createElement('a');this.home.className='plain';this.home.textContent=BridgeI18n.t('返回电脑列表');this.home.dataset.i18nAriaLabel='返回电脑列表';this.home.setAttribute('aria-label',BridgeI18n.t('返回电脑列表'));this.home.href='#computers';this.home.id='computers-button';this.home.onclick=event=>{if(BridgeHost.hasNativeLayout()){this.home.href='codexbridge://home';return;}event.preventDefault();this.homeDialog();};
this.footer.append(this.manage,this.home);this.syncHome();new MutationObserver(()=>this.syncHome()).observe(document.documentElement,{attributes:true,attributeFilter:['class']});
document.getElementById('logout').hidden=true;
this.badge=document.createElement('span');this.badge.className='client-badge';this.heading.querySelector('h2').append(this.badge);
@@ -20,14 +42,15 @@ class ClientNavigation {
const preferences=document.getElementById('appearance-activity').closest('fieldset');
for(const [key,label] of [['clients','应用管理']]){const row=document.createElement('label'),box=document.createElement('input');row.className='display-option';box.type='checkbox';box.id='appearance-'+key;box.checked=this.preference(key);box.onchange=()=>{this.preference(key,box.checked);this.paint();};const caption=document.createElement('span');caption.dataset.i18n=label;caption.textContent=BridgeI18n.t(label);row.append(caption,box);preferences.append(row);}
const oldShortcuts=document.getElementById('appearance-accounts').closest('fieldset');
- for(const [id,label] of [['appearance-accounts','账号管理'],['appearance-pushplus','通知管理']]){const row=document.getElementById(id).closest('label'),caption=row.querySelector('span');caption.dataset.i18n=label;caption.textContent=BridgeI18n.t(label);preferences.append(row);}
+ for(const [id,label] of [['appearance-accounts','账号管理'],['appearance-pushplus','显示第三方通知入口']]){const row=document.getElementById(id).closest('label'),caption=row.querySelector('span');caption.dataset.i18n=label;caption.textContent=BridgeI18n.t(label);preferences.append(row);}
+ if(window.BridgeSharedRelay)document.getElementById('appearance-pushplus').closest('label').hidden=true;
oldShortcuts.hidden=true;
- const settings=document.getElementById('appearance-dialog');settings.querySelector('.picker-head').after(this.switchToggle);
+ const settings=document.getElementById('appearance-dialog');this.settingsActions=document.createElement('div');this.settingsActions.className='settings-action-row';settings.querySelector('.picker-head').after(this.settingsActions);this.settingsActions.append(this.switchToggle);
const switchPreferences=document.createElement('fieldset');switchPreferences.className='appearance-section';const switchTitle=document.createElement('legend');switchTitle.dataset.i18n='底部应用切换';switchTitle.textContent=BridgeI18n.t('底部应用切换');switchPreferences.append(switchTitle);
for(const [id,name] of [['codex','Codex'],['claude','Claude'],['deepseek','DeepSeek Harness']]){const row=document.createElement('label'),box=document.createElement('input'),caption=document.createElement('span');row.className='display-option';box.type='checkbox';box.id='appearance-switch-'+id;box.checked=this.preference('switch:'+id);box.onchange=()=>{this.preference('switch:'+id,box.checked);this.paint();};caption.textContent=name;row.append(caption,box);switchPreferences.append(row);}const switchHint=document.createElement('p');switchHint.className='muted';switchHint.dataset.i18n='隐藏图标不关闭应用,仍可在应用管理中打开聊天。';switchHint.textContent=BridgeI18n.t('隐藏图标不关闭应用,仍可在应用管理中打开聊天。');switchPreferences.append(switchHint);preferences.after(switchPreferences);
const swipeRow=document.createElement('label'),swipeBox=document.createElement('input'),swipeCaption=document.createElement('span');swipeRow.className='display-option';swipeBox.type='checkbox';swipeBox.id='appearance-swipe-clients';swipeBox.checked=this.preference('swipe-clients',undefined,true);swipeBox.onchange=()=>{this.preference('swipe-clients',swipeBox.checked);this.cancelListSwipe();};swipeCaption.dataset.i18n='左右滑动切换应用';swipeCaption.textContent=BridgeI18n.t('左右滑动切换应用');swipeRow.append(swipeCaption,swipeBox);switchPreferences.append(swipeRow);this.bindListGestures();
const language=document.getElementById('language');if(language)preferences.append(language.closest('label'));
- const applications=make('应用管理',()=>{settings.close();this.openManager();});settings.querySelector('.appearance-actions').insertBefore(applications,document.getElementById('appearance-reset').nextSibling);
+ const applications=make('应用管理',()=>{settings.close();this.openManager();});applications.dataset.i18nAriaLabel='应用管理';applications.setAttribute('aria-label',BridgeI18n.t('应用管理'));this.settingsNotifications=make('第三方通知',()=>{if(window.BridgeSharedRelay)return;settings.close();this.notifications.click();});this.settingsNotifications.removeAttribute('data-i18n');this.settingsNotifications.dataset.i18nAriaLabel='第三方通知';this.settingsNotifications.setAttribute('aria-label',BridgeI18n.t('第三方通知'));this.settingsActions.append(applications,this.settingsNotifications);
this.account=document.getElementById('accounts-button');this.notifications=document.getElementById('pushplus-settings');
// Older native shells move these controls after page load. Keep the new
// two-row navigation intact while preserving their native home action.
@@ -46,6 +69,12 @@ class ClientNavigation {
this.timer=setInterval(()=>this.poll(),15000);this.connectingTimer=setInterval(()=>this.poll(true),2000);this.paint();
}
syncHome(){this.home.hidden=!BridgeHost.hasNativeLayout();}
+ paintSettingsActions(){
+ this.settingsNotifications.hidden=!!window.BridgeSharedRelay;
+ const count=1+Number(!this.switchToggle.hidden)+Number(!this.settingsNotifications.hidden);
+ this.settingsActions.classList.toggle('two-actions',count===2);
+ this.settingsActions.classList.toggle('one-action',count===1);
+ }
preference(key,value,fallback=true){if(value!==undefined)localStorage.setItem('navigation:'+key,String(value));const saved=localStorage.getItem('navigation:'+key);return saved==null?fallback:saved!=='false';}
bindListGestures(){
this.swipeSnapshots=new Map();
@@ -165,7 +194,7 @@ class ClientNavigation {
}
connection(client){return ClientLifecycle.connection({...client,pendingEnable:this.gatewayRunning!==false&&this.pending.has(client.id)&&client.enabled});}
hasUncertain(){return [...(this.operationFailures?.values()||[])].some(value=>value.uncertain);}
- syncViewClients(){this.view.setClientStates?.(this.clients.map(client=>({...client,operationFailure:this.operationFailures?.get(client.id),operationUncertain:this.hasUncertain(),pendingEnable:this.gatewayRunning!==false&&this.pending.has(client.id)&&client.enabled})),id=>this.toggle(id,true),id=>this.initialize(id),()=>this.refresh());}
+ syncViewClients(){this.view.setClientStates?.(this.clients.map(client=>({...client,operationFailure:this.operationFailures?.get(client.id),operationUncertain:this.hasUncertain(),pendingEnable:this.gatewayRunning!==false&&this.pending.has(client.id)&&client.enabled})),id=>this.toggle(id,true),id=>this.initialize(id),()=>this.refresh(),id=>this.reconnect(id));}
retryOperation(id){const failure=this.operationFailures?.get(id);if(!failure||this.hasUncertain())return;return failure.action==='initialize'?this.initialize(id):this.toggle(id,failure.action==='enable');}
async choose(id){
this.cancelListSwipe();
@@ -223,7 +252,7 @@ class ClientNavigation {
finally{this.pending.delete(id);this.pendingQuitDesktop=false;this.pendingForceDesktop=false;++this.revision;this.paint();if(failed)await this.refresh();}
}
async reconnect(id){
- const client=this.clients.find(row=>row.id===id);if(this.pending.size||id!=='claude'||!client?.reconnectSupported||!client.canReconnect||client.setupStatus==='connecting')return;
+ const client=this.clients.find(row=>row.id===id);if(this.pending.size||this.hasUncertain()||id!=='claude'||!this.connection(client||{}).canReconnect)return;
this.pending.add(id);this.reconnecting=id;++this.revision;this.paint();
try{const data=await this.request('/api/clients/claude/reconnect',{});this.applyClients(data);const connected=data.clients.find(row=>row.id===id)?.connected;this.notify(connected?'Claude 已连接':'已发起重新连接');if(connected&&this.provider===id)await this.view.refresh();}
finally{this.pending.delete(id);this.reconnecting=null;++this.revision;this.paint();}
@@ -233,8 +262,8 @@ class ClientNavigation {
const provider=this.provider,row=this.clients.find(c=>c.id===provider);this.badge.replaceChildren();if(provider)this.badge.append(this.icon(provider),document.createTextNode(row?.name||'Codex'));
const target=provider&&provider!=='codex'?this.view.list:this.sidebar;target.prepend(this.heading);target.append(this.navigation);
this.account.classList.toggle('navigation-hidden',!provider||(provider==='codex'&&!!window.BridgeSharedRelay));this.notifications.classList.toggle('navigation-hidden',!provider);this.manage.hidden=!this.preference('clients');
- this.account.textContent=BridgeI18n.t('账号管理');this.account.removeAttribute('data-i18n');this.notifications.textContent=BridgeI18n.t('通知管理');this.notifications.removeAttribute('data-i18n');
- this.switcher.replaceChildren();const enabled=this.clients.filter(c=>c.enabled),visible=this.visibleClients();const collapsed=this.preference('switch-collapsed',undefined,false);this.switchToggle.hidden=enabled.length<2||!visible.length;this.switchDock.hidden=this.switchToggle.hidden||collapsed;this.navigation.classList.toggle('switch-dock-hidden',this.switchDock.hidden);this.switchToggle.textContent=BridgeI18n.t(collapsed?'显示APP':'隐藏APP');this.switchToggle.setAttribute('aria-expanded',String(!collapsed));this.switchToggle.setAttribute('aria-label',BridgeI18n.t(collapsed?'展开应用图标':'收起应用图标'));this.switchToggle.title=this.switchToggle.getAttribute('aria-label');
+ this.account.textContent=BridgeI18n.t('账号管理');this.account.removeAttribute('data-i18n');this.notifications.textContent=BridgeI18n.language()==='en'?'Push alerts':'第三方通知';this.notifications.removeAttribute('data-i18n');this.notifications.setAttribute('aria-label',BridgeI18n.t('第三方通知'));this.home.textContent=BridgeI18n.language()==='en'?'Computers':'返回电脑列表';
+ this.switcher.replaceChildren();const enabled=this.clients.filter(c=>c.enabled),visible=this.visibleClients();const collapsed=this.preference('switch-collapsed',undefined,false);this.switchToggle.hidden=enabled.length<2||!visible.length;this.paintSettingsActions();this.switchDock.hidden=this.switchToggle.hidden||collapsed;this.navigation.classList.toggle('switch-dock-hidden',this.switchDock.hidden);this.switchToggle.textContent=BridgeI18n.t(collapsed?'显示底部 APP 图标':'隐藏底部 APP 图标');this.switchToggle.setAttribute('aria-expanded',String(!collapsed));this.switchToggle.setAttribute('aria-label',BridgeI18n.t(collapsed?'展开应用图标':'收起应用图标'));this.switchToggle.title=this.switchToggle.getAttribute('aria-label');this.settingsNotifications.textContent=BridgeI18n.language()==='en'?'Push alerts':'第三方通知';
for(const client of visible){const button=document.createElement('button');button.type='button';button.append(this.icon(client.id),document.createTextNode(client.name));button.classList.toggle('selected',client.id===provider);button.setAttribute('aria-pressed',String(client.id===provider));button.onclick=()=>this.choose(client.id).catch(e=>this.notify(e.message));this.switcher.append(button);}
document.getElementById('desktop-backend').hidden=true;
document.getElementById('new-chat').disabled=!provider;
@@ -265,7 +294,8 @@ class ClientNavigation {
if(client.enabled){const open=document.createElement('button');open.type='button';open.textContent=BridgeI18n.t('查看聊天');open.onclick=()=>{manager.dialog.close();this.choose(client.id).catch(error=>this.notify(error.message));};control.append(open);}
if(failure){const retry=document.createElement('button');retry.type='button';retry.textContent=BridgeI18n.t(failure.uncertain?'刷新状态':ClientLifecycle.retryLabel(failure.action));retry.disabled=this.pending.size>0||!!this.choosing||!!this.loading||(!failure.uncertain&&this.hasUncertain());retry.onclick=()=>Promise.resolve(failure.uncertain?this.refresh():this.retryOperation(client.id)).catch(error=>this.notify(error.message));control.append(retry);}
if(!failure&&state.retryable&&this.gatewayRunning!==false){const retry=document.createElement('button');retry.type='button';retry.textContent=BridgeI18n.t('重试连接');retry.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();retry.onclick=()=>this.toggle(client.id,true).catch(error=>this.notify(error.message));control.append(retry);}
- if(!failure&&state.canInitialize&&this.gatewayRunning!==false){const initialize=document.createElement('button');initialize.type='button';initialize.textContent=BridgeI18n.t('初始化连接');initialize.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();initialize.onclick=()=>this.initialize(client.id).catch(error=>this.notify(error.message));control.append(initialize);}
+ if(!failure&&state.canReconnect&&this.gatewayRunning!==false){const reconnect=document.createElement('button');reconnect.type='button';reconnect.textContent=BridgeI18n.t('尝试重连');reconnect.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();reconnect.onclick=()=>this.reconnect(client.id).catch(error=>this.notify(error.message));control.append(reconnect);}
+ if(!failure&&state.canInitialize&&this.gatewayRunning!==false){const initialize=document.createElement('button');initialize.type='button';initialize.textContent=BridgeI18n.t(state.canReconnect?'重新初始化连接':'初始化连接');initialize.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();initialize.onclick=()=>this.initialize(client.id).catch(error=>this.notify(error.message));control.append(initialize);}
}
note.textContent=BridgeI18n.t(this.gatewayRunning===false?'网关未启动,开关仅保存下次启动时的选择,不会打开或退出应用。':'安装和登录请在电脑端完成。关闭接入可保留或退出电脑 App。macOS Claude 默认后台启动并尝试恢复已有连接;“初始化连接”经确认后使用电脑前台。Windows 优先尝试后台初始化。')+' '+BridgeI18n.t(ClientLifecycle.sessionNotice(this.windowsSession));
}
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index 97071a48..351b8d6a 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -6,13 +6,14 @@ class DesktopSessionsView {
this.readTasks=new Map();this.readEpoch=0;this.providerReadEpochs=new Map();this.listPrefetchAt=new Map();this.detailCacheSizes=new Map();this.detailCacheBytes=0;
this.container=this.node('section','desktop-sessions');root.append(this.container);this.container.hidden=true;
this.list=this.node('aside','ds-list');this.main=this.node('main','ds-main');this.container.append(this.list,this.main);
- this.search=this.node('input','search');this.search.placeholder=BridgeI18n.t('搜索聊天或项目');this.label(this.search,'搜索聊天或项目');this.search.oninput=()=>this.renderList();
+ this.search=this.node('input','search');this.search.placeholder=BridgeI18n.t('搜索');this.label(this.search,'搜索聊天或项目');this.search.oninput=()=>this.renderList();
const filters=this.node('div','list-filters');const display=this.node('label','list-mode');display.append(this.translated('span','显示方式'));this.listMode=this.node('select');this.label(this.listMode,'聊天列表显示方式');for(const [id,title] of [['recent','最近交互'],['project','按项目']])this.listMode.add(new Option(BridgeI18n.t(title),id));this.listMode.onchange=()=>this.renderList();display.append(this.listMode);
const archive=this.node('label','archive');this.archived=this.node('input');this.archived.type='checkbox';this.archived.onchange=()=>this.renderList();archive.append(this.archived,this.translated('span','已归档'));filters.append(display,archive);
- this.surface=this.node('select','ds-surface');this.label(this.surface,'会话类型');for(const [id,title] of [['all','全部会话'],['code','Code'],['cowork','Cowork']])this.surface.add(new Option(BridgeI18n.t(title),id));this.surface.onchange=()=>this.renderList();
+ this.surface=this.node('select','ds-surface');this.label(this.surface,'会话类型');for(const [id,title] of [['all','全部会话'],['code','Code'],['cowork','Cowork']])this.surface.add(new Option(BridgeI18n.t(title),id));this.surface.onchange=()=>this.renderList();filters.prepend(this.surface);
this.listStatus=this.node('p','muted ds-list-status');this.listStatus.setAttribute('role','status');this.connectionRetry=this.button('重试连接',async()=>{const provider=this.provider;if(this.retryingClient||!this.retryClient)return;this.retryingClient=provider;this.renderConnectionStatus();try{await this.retryClient(provider);}finally{this.retryingClient=null;this.renderConnectionStatus();}});this.connectionRetry.hidden=true;
this.connectionInitialize=this.button('初始化连接',async()=>{const provider=this.provider;if(this.initializingClient||!this.initializeClient)return;this.initializingClient=provider;this.renderConnectionStatus();try{await this.initializeClient(provider);}finally{this.initializingClient=null;this.renderConnectionStatus();}});this.connectionInitialize.hidden=true;
- this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.surface,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
+ this.connectionReconnect=this.button('尝试重连',async()=>{const provider=this.provider;if(this.reconnectingClient||!this.reconnectClient)return;this.reconnectingClient=provider;this.renderConnectionStatus();try{await this.reconnectClient(provider);}finally{this.reconnectingClient=null;this.renderConnectionStatus();}});this.connectionReconnect.hidden=true;
+ this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionReconnect,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);if(typeof bindChatListToolbar==='function')bindChatListToolbar(toolbar);
this.welcome=this.node('div','welcome ds-welcome');const logo=this.node('img','mark large');logo.src='/icon.png';logo.alt='';logo.width=72;logo.height=72;this.welcome.append(logo,this.translated('h1','从一条聊天继续'),this.translated('p','选择电脑上的聊天,查看进度或发送消息。','muted'));
this.conversation=this.node('section','ds-conversation chat');this.conversation.hidden=true;this.main.append(this.welcome,this.conversation);
this.back=this.iconButton('返回聊天列表','m15 5-7 7 7 7',()=>{if(!this.workbench.back())this.backToList();});this.back.classList.add('ds-back');
@@ -30,8 +31,10 @@ class DesktopSessionsView {
this.attachments=new ChatAttachments({root:this.attachmentList,button:this.attachButton,input:this.attachmentInput,paste:this.input,drop:this.form,onChange:()=>this.updateComposer(),upload:(key,id,file)=>this.uploadAttachment(key,id,file),preview:(key,id)=>this.attachmentUrl(key,id,'preview'),thumbnail:async(key,id,file)=>{const image=await this.attachments.thumbnailBlob(file);return this.uploadBinary(this.attachmentUrl(key,id,'thumb')+'&width='+image.width+'&height='+image.height,image.data,image.mime);}});
const addAttachments=this.attachments.add.bind(this.attachments);this.attachments.add=files=>{if(!this.attachments.key||this.attachments.locked)return;if(this.overRequestLimit([...this.attachments.rows,...files])){this.attachments.error=this.requestLimitMessage();this.attachments.render();return;}if(files.some(file=>!file.type?.startsWith('image/'))){this.attachments.error='此应用的消息附件仅支持图片;其他文件请使用文件传输。';this.attachments.render();return;}addAttachments(files);};
this.conversation.append(head,this.notice,this.messages,this.requests,this.form);this.workbench=new Workbench({chat:this.conversation,request,csrf,notify,onUnauthorized});select.onchange=()=>this.choose(select.value);
- document.addEventListener('bridge-language',()=>this.relabel());document.addEventListener('visibilitychange',()=>{clearTimeout(this.timer);if(!document.hidden){this.prefetchLists();if(this.provider!=='codex')this.refresh();}});
+ document.addEventListener('bridge-language',()=>this.relabel());const resume=()=>{clearTimeout(this.timer);if(document.hidden)this.listSync.reset();else{this.prefetchLists();if(this.provider!=='codex')this.refresh();}};document.addEventListener('visibilitychange',resume);window.addEventListener('focus',resume);
this.appearanceObserver=new MutationObserver(()=>{this.renderMessages(this.lastMessages||[]);this.workbench.setThumbnails(document.documentElement.dataset.showFileThumbnails==='true');});this.appearanceObserver.observe(document.documentElement,{attributes:true,attributeFilter:['data-show-reasoning','data-show-process','data-show-file-thumbnails']});
+ const listRegion=this.node('div','session-list-region');this.listHint=this.button('',()=>{});this.listHint.className='list-sync';this.listHint.hidden=true;this.listHint.setAttribute('aria-live','polite');listRegion.append(this.rowsRoot,this.listHint);this.list.append(listRegion);
+ this.listSync=new ListSync({root:this.rowsRoot,status:this.listHint,refresh:()=>this.refreshList(),visible:()=>!this.root.hidden&&!this.container.hidden&&this.list.getBoundingClientRect().width>0,ready:()=>document.activeElement!==this.search,automatic:false});
this.updateComposer();
}
node(tag,cls='',text){const n=document.createElement(tag);n.className=cls;if(text!==undefined)n.textContent=text;return n;}
@@ -59,7 +62,7 @@ class DesktopSessionsView {
invalidateReads(provider){
this.providerReadEpochs.set(provider,(this.providerReadEpochs.get(provider)||0)+1);for(const key of this.readTasks.keys())if(JSON.parse(key)[0]===provider)this.readTasks.delete(key);
this.listPrefetchAt.delete(provider);const saved=this.providerViews.get(provider);if(saved)saved.cachedAt=0;
- if(provider===this.provider){this.freshDetail=false;this.detailRevision++;this.loading=null;this.refreshPending=null;}
+ if(provider===this.provider){this.freshDetail=false;this.detailRevision++;this.loading=null;this.listSync.reset();}
}
prefetchLists(){
if(document.hidden||this.root.hidden)return;
@@ -69,20 +72,22 @@ class DesktopSessionsView {
this.listPrefetchAt.set(client.id,now);this.sharedRead(client.id,'list').promise.catch(()=>{});
}
}
- setClientStates(clients,retry,initialize,refresh){
+ setClientStates(clients,retry,initialize,refresh,reconnect){
for(const client of clients){const old=this.clientStates?.find(row=>row.id===client.id);if(old&&(old.enabled!==false&&client.enabled===false||old.connected===true&&client.connected===false))this.invalidateReads(client.id);}
- const before=this.clientStates?.find(client=>client.id===this.provider);this.clientStates=clients;this.retryClient=retry;this.initializeClient=initialize;this.refreshClients=refresh;
+ const before=this.clientStates?.find(client=>client.id===this.provider);this.clientStates=clients;this.retryClient=retry;this.initializeClient=initialize;this.refreshClients=refresh;this.reconnectClient=reconnect;
if(this.state&&!this.canMutate())this.staleDetail();else this.updateComposer();
const current=clients.find(client=>client.id===this.provider),wasWaiting=before&&ClientLifecycle.connection(before).waiting;
this.prefetchLists();
- if(!this.renderConnectionStatus()&&before&&(wasWaiting||(!before.connected&¤t?.connected))){this.listStatus.textContent=BridgeI18n.t('正在读取聊天…');return this.refresh();}
+ if(!this.renderConnectionStatus()&&before&&(wasWaiting||(!before.connected&¤t?.connected))){this.listStatus.textContent=this.rows.length?'':BridgeI18n.t('正在读取聊天…');return this.refresh();}
}
renderConnectionStatus(){
const client=this.clientStates?.find(client=>client.id===this.provider),state=client&&ClientLifecycle.connection(client);
- const failure=client?.operationFailure,uncertain=!!(client?.operationUncertain||failure?.uncertain),blocked=!!state&&!client.connected&&(state.waiting||state.action||state.retryable||!!failure);
+ const failure=client?.operationFailure,uncertain=!!(client?.operationUncertain||failure?.uncertain),blocked=!!state&&!client.connected&&(state.waiting||state.action||state.retryable||state.canReconnect||!!failure);
this.listStatus.setAttribute('aria-busy',String(!!state?.waiting));this.listStatus.classList.toggle('client-connection-waiting',!!state?.waiting);
this.connectionRetry.hidden=!blocked||!state.retryable||uncertain;this.connectionRetry.disabled=!!this.retryingClient;
this.connectionInitialize.hidden=!blocked||!state.canInitialize||uncertain;this.connectionInitialize.disabled=!!this.initializingClient;
+ this.connectionReconnect.hidden=!blocked||!state.canReconnect||uncertain;this.connectionReconnect.disabled=!!this.reconnectingClient;
+ this.connectionInitialize.textContent=BridgeI18n.t(state?.canReconnect?'重新初始化连接':'初始化连接');
this.connectionNotice.hidden=!failure;this.connectionNotice.textContent=failure?BridgeI18n.t(ClientLifecycle.failureMessage(failure)):'';this.connectionRefresh.hidden=!uncertain;this.connectionRefresh.disabled=!!this.refreshingClient;
if(blocked)this.listStatus.textContent=BridgeI18n.t(state.label)+(state.reason&&state.reason!==state.label?' · '+BridgeI18n.t(state.reason):'');
return blocked;
@@ -95,8 +100,8 @@ class DesktopSessionsView {
}
async choose(provider){
if(provider===this.provider)return;
- this.rememberView();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider=provider;this.select.value=provider;this.workbench.provider=provider;this.workbench.current=null;this.workbench.panel.hidden=true;this.workbench.tabs.replaceChildren();this.conversation.classList.remove('workbench-active','wb-split');this.sid=null;this.state=null;this.freshDetail=false;const saved=this.providerViews.get(provider);this.rows=saved?.rows||[];this.listStamp='';this.search.value=saved?.query||'';this.surface.value=saved?.surface||'all';this.archived.checked=saved?.archived||false;if(saved?.listMode)this.listMode.value=saved.listMode;this.surface.hidden=provider!=='claude';this.showSelection(false);
- this.container.hidden=provider==='codex';this.root.classList.toggle('desktop-mode',provider!=='codex');this.clientIcon.replaceChildren();if(provider!=='codex'){const img=this.node('img');img.src='/client-icons/'+provider+'.png';img.alt='';this.clientIcon.append(img);}this.clientIcon.className='client-icon client-'+provider;this.listStatus.textContent=BridgeI18n.t('正在读取聊天…');this.renderList();this.rowsRoot.scrollTop=saved?.scroll||0;this.updateComposer();if(saved?.sid)this.prepareChat(saved.sid);this.onSelect(provider);
+ this.rememberView();this.listSync.reset();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider=provider;this.select.value=provider;this.workbench.provider=provider;this.workbench.current=null;this.workbench.panel.hidden=true;this.workbench.tabs.replaceChildren();this.conversation.classList.remove('workbench-active','wb-split');this.sid=null;this.state=null;this.freshDetail=false;const saved=this.providerViews.get(provider);this.rows=saved?.rows||[];this.listStamp='';this.search.value=saved?.query||'';this.surface.value=saved?.surface||'all';this.archived.checked=saved?.archived||false;if(saved?.listMode)this.listMode.value=saved.listMode;this.surface.hidden=provider!=='claude';this.showSelection(false);
+ this.container.hidden=provider==='codex';this.root.classList.toggle('desktop-mode',provider!=='codex');this.clientIcon.replaceChildren();if(provider!=='codex'){const img=this.node('img');img.src='/client-icons/'+provider+'.png';img.alt='';this.clientIcon.append(img);}this.clientIcon.className='client-icon client-'+provider;this.listStatus.textContent=this.rows.length?'':BridgeI18n.t('正在读取聊天…');this.renderList();this.rowsRoot.scrollTop=saved?.scroll||0;this.updateComposer();if(saved?.sid)this.prepareChat(saved.sid);this.onSelect(provider);
this.renderConnectionStatus();if(provider!=='codex')await this.refresh();
}
backToList(){if(!this.isChatOpen)return false;this.saveDraft();this.workbench.rememberScroll();this.sid=null;this.state=null;this.freshDetail=false;this.detailRevision++;this.showSelection(false);this.renderList();this.updateComposer();this.rememberView();this.onListEnter?.();return true;}
@@ -116,16 +121,24 @@ class DesktopSessionsView {
if(provider!==this.provider)return;
this.rows=[];this.listStamp='';this.lastMessages=[];this.requestStamp='';this.messageNodes.clear();this.messages.replaceChildren();this.requests.replaceChildren();this.showSelection(false);this.renderList();this.updateComposer();await this.refresh();
}
- clear(){this.readEpoch++;this.readTasks.clear();this.providerReadEpochs.clear();this.listPrefetchAt.clear();this.detailCacheSizes.clear();this.detailCacheBytes=0;this.clientStates=[];this.workbench.reset();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider='codex';this.select.value='codex';this.select.hidden=true;this.container.hidden=true;this.root.classList.remove('desktop-mode');this.sid=null;this.state=null;this.showSelection(false);this.drafts.clear();this.accountDrafts.clear();this.skillSelections.clear();this.providerViews.clear();this.detailCache.clear();this.activity.clear();this.freshDetail=false;this.rows=[];this.listStamp='';this.rowsRoot.replaceChildren();this.lastMessages=[];this.requestStamp='';this.pending.clear();this.attachments.reset();this.messages.replaceChildren();this.requests.replaceChildren();this.messageNodes.clear();}
+ clear(){this.listSync.reset();this.readEpoch++;this.readTasks.clear();this.providerReadEpochs.clear();this.listPrefetchAt.clear();this.detailCacheSizes.clear();this.detailCacheBytes=0;this.clientStates=[];this.workbench.reset();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider='codex';this.select.value='codex';this.select.hidden=true;this.container.hidden=true;this.root.classList.remove('desktop-mode');this.sid=null;this.state=null;this.showSelection(false);this.drafts.clear();this.accountDrafts.clear();this.skillSelections.clear();this.providerViews.clear();this.detailCache.clear();this.activity.clear();this.freshDetail=false;this.rows=[];this.listStamp='';this.rowsRoot.replaceChildren();this.lastMessages=[];this.requestStamp='';this.pending.clear();this.attachments.reset();this.messages.replaceChildren();this.requests.replaceChildren();this.messageNodes.clear();}
async refresh(){
clearTimeout(this.timer);const generation=this.generation,provider=this.provider;if(provider==='codex'||document.hidden||this.root.hidden)return;
if(this.renderConnectionStatus()){this.updateActivity(this.rows,false);this.renderIndicators();this.freshDetail=false;this.updateComposer();return;}
- if(this.loading?.generation===generation){this.refreshPending=generation;return;}const loading={generation};this.loading=loading;
+ if(this.loading?.generation===generation)return this.loading.promise;const loading={generation};this.loading=loading;
if(this.sid)this.detail(generation).catch(error=>{if(generation===this.generation){this.error.textContent=BridgeI18n.t(error.message);this.status.textContent=BridgeI18n.t('连接失败');}});
- const task=this.sharedRead(provider,'list');
- try{const value=await task.promise;if(!task.current()||generation!==this.generation)return;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=BridgeI18n.t(value.connected===false?'尚未连接':this.rows.length?'已连接桌面':'已连接,桌面还没有会话');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);}
- catch(error){if(task.current()&&generation===this.generation){this.updateActivity(this.rows,false);this.renderIndicators();if(!this.renderConnectionStatus())this.listStatus.textContent=BridgeI18n.t(error.message);if(this.sid&&!this.state)this.error.textContent=this.listStatus.textContent;}}
- finally{if(this.loading===loading){this.loading=null;if(generation===this.generation){if(this.refreshPending===generation){this.refreshPending=null;this.refresh();}else this.timer=setTimeout(()=>this.refresh(),this.sid||ClientLifecycle.connection(this.clientStates?.find(client=>client.id===provider)).waiting?2000:5000);}}}
+ loading.promise=this.refreshList().catch(()=>{}).finally(()=>{if(this.loading===loading){this.loading=null;if(generation===this.generation)this.timer=setTimeout(()=>this.refresh(),this.sid||ClientLifecycle.connection(this.clientStates?.find(client=>client.id===provider)).waiting?2000:5000);}});
+ return loading.promise;
+ }
+ refreshList(){
+ const generation=this.generation,provider=this.provider;if(provider==='codex'||document.hidden||this.root.hidden||this.renderConnectionStatus())return Promise.resolve(false);
+ const task=this.sharedRead(provider,'list');if(this.listRead?.task===task&&this.listRead.generation===generation)return this.listRead.promise;
+ const reading={task,generation};this.listRead=reading;
+ reading.promise=(async()=>{
+ try{const value=await task.promise;if(!task.current()||generation!==this.generation)return false;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=value.connected===false?BridgeI18n.t('尚未连接'):this.rows.length?'':BridgeI18n.t('电脑上还没有聊天');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);if(value.connected===false)throw Error(BridgeI18n.t('尚未连接'));this.listSync.acknowledgeSuccess();return true;}
+ catch(error){if(!task.current()||generation!==this.generation)return false;this.updateActivity(this.rows,false);this.renderIndicators();if(!this.renderConnectionStatus()){this.listStatus.textContent=this.rows.length?'':BridgeI18n.t(error.message);if(!this.listSync.busy)this.listSync.reportFailure(error);}if(this.sid&&!this.state)this.error.textContent=BridgeI18n.t(error.message);throw error;}
+ finally{if(this.listRead===reading)this.listRead=null;}
+ })();return reading.promise;
}
visibleActivity(){return this.isChatOpen&&!document.hidden&&!this.workbench.isFileVisible?this.provider+'|'+this.sid:null;}
updateActivity(rows,connected=true){this.activity.update(rows.map(row=>({id:row.id,host:this.provider,connected,status:row.status,turnId:row.turnId,turnStatus:row.turnStatus})),this.visibleActivity());}
@@ -133,13 +146,13 @@ class DesktopSessionsView {
const rows=new Map(this.rows.map(row=>[row.id,row]));for(const button of this.rowsRoot.querySelectorAll('.session')){button.querySelector('.session-indicator')?.remove();const row=this.freshDetail&&button.dataset.id===this.sid?this.state:rows.get(button.dataset.id),indicator=row?.requests?.length?(this.activity.rows[this.provider+'|'+button.dataset.id]?.offline?{kind:'unknown',label:'运行状态暂不可用'}:{kind:'awaiting',label:'等待回应'}):this.activity.indicator(this.provider+'|'+button.dataset.id);if(indicator){const dot=this.node('span','session-indicator '+indicator.kind);dot.title=BridgeI18n.t(indicator.label);dot.setAttribute('aria-label',BridgeI18n.t(indicator.label));button.querySelector('strong')?.prepend(dot);}}
}
renderList(){
- const query=this.search.value.trim().toLowerCase(),surface=this.provider==='claude'?this.surface.value:'all';const stamp=JSON.stringify([this.provider,this.sid,query,surface,this.archived.checked,this.listMode.value,this.rows,BridgeI18n.language()]);if(stamp===this.listStamp){this.renderIndicators();return;}this.listStamp=stamp;this.rowsRoot.replaceChildren();
+ const query=this.search.value.trim().toLowerCase(),surface=this.provider==='claude'?this.surface.value:'all';const stamp=JSON.stringify([this.provider,this.sid,query,surface,this.archived.checked,this.listMode.value,this.rows,BridgeI18n.language()]);if(stamp===this.listStamp){this.renderIndicators();return;}this.listStamp=stamp;const scroll=this.rowsRoot.scrollTop,top=this.rowsRoot.getBoundingClientRect().top,anchor=scroll>0?[...this.rowsRoot.querySelectorAll('.session')].find(node=>node.getBoundingClientRect().bottom>top):null,offset=anchor?anchor.getBoundingClientRect().top-top:0;this.rowsRoot.replaceChildren();
const rows=(this.rows||[]).filter(row=>(this.archived.checked||!row.archived)&&(surface==='all'||row.backend===surface)&&((row.title||'')+' '+(row.cwd||'')).toLowerCase().includes(query)).sort((a,b)=>Number(b.updatedAt||0)-Number(a.updatedAt||0));
const groups=new Map();for(const row of rows){const key=[this.provider==='claude'?(row.backend==='cowork'?'Cowork':'Code'):'',this.listMode.value==='project'?row.cwd||BridgeI18n.t('电脑上的聊天'):''].filter(Boolean).join(' · ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(row);}
for(const [name,items] of groups){let target=this.rowsRoot;if(name){const group=this.node('details','project-group ds-session-group'),key=JSON.stringify([this.provider,this.listMode.value,name]);group.open=!this.collapsedGroups.has(key);group.ontoggle=()=>{if(!group.isConnected)return;if(group.open)this.collapsedGroups.delete(key);else this.collapsedGroups.add(key);try{localStorage.setItem('bridge-desktop-session-groups',JSON.stringify([...this.collapsedGroups]));}catch{}};group.append(this.node('summary','',name));this.rowsRoot.append(group);target=group;}
for(const row of items){const button=this.button('',()=>this.open(row.id));button.className='session'+(row.id===this.sid?' selected':'');button.dataset.id=row.id;button.dataset.provider=this.provider;const meta=this.node('small'),location=this.node('span','',row.cwd||''),time=this.node('time');if(row.updatedAt){const raw=Number(row.updatedAt);const date=new Date(raw<1e12?raw*1000:raw);if(Number.isFinite(date.getTime()))time.textContent=date.toLocaleDateString(BridgeI18n.locale(),{month:'numeric',day:'numeric'});}meta.append(location,time);button.append(this.node('strong','',row.title||BridgeI18n.t('新会话')),meta);target.append(button);}
}
- if(!rows.length&&this.rows.length)this.rowsRoot.append(this.translated('p','没有匹配的聊天','muted ds-list-empty'));this.renderIndicators();
+ if(!rows.length&&this.rows.length)this.rowsRoot.append(this.translated('p','没有匹配的聊天','muted ds-list-empty'));this.rowsRoot.scrollTop=scroll;if(anchor){const next=[...this.rowsRoot.querySelectorAll('.session')].find(node=>node.dataset.id===anchor.dataset.id);if(next)this.rowsRoot.scrollTop+=next.getBoundingClientRect().top-this.rowsRoot.getBoundingClientRect().top-offset;}this.renderIndicators();
}
prepareChat(sid){
this.saveDraft();this.sid=sid;this.state=null;this.freshDetail=false;this.detailRevision++;const row=this.rows.find(r=>r.id===sid);this.title.textContent=row?.title||BridgeI18n.t('正在读取聊天…');this.status.textContent=BridgeI18n.t('正在连接…');this.input.value=this.drafts.get(this.provider+':'+sid)||'';this.requestStamp='';this.error.textContent='';this.notice.hidden=true;this.messages.replaceChildren(this.translated('p','正在读取聊天…','muted'));this.messageNodes.clear();this.lastMessages=[];this.requests.replaceChildren();this.showSelection(true);this.attachments.open(JSON.stringify([this.provider,sid]));this.workbench.open(sid,'local');const cached=this.detailCache.get(JSON.stringify([this.provider,sid]));if(cached){this.applyDetail(cached,true);this.messages.scrollTop=this.workbench.current.scroll||0;}this.activity.read(this.provider+'|'+sid);this.updateComposer();this.renderList();this.resizeInput();this.rememberView();
@@ -259,7 +272,7 @@ class DesktopSessionsView {
for(const [id,text] of [['inherit','跟随全局设置'],['on','开启'],['off','未开启']]){const option=this.translated('option',text);option.value=id;select.add(option);}select.value=value[key]||'inherit';fields[key]=select;form.append(label);
}
const error=this.node('p','error');error.setAttribute('role','alert');const save=this.button('保存配置',()=>{});save.type='submit';save.className='primary';
- form.append(this.translated('p',value.available?'通知通道已开启':'尚未配置通知通道,可在设置中配置 PushPlus,或在电脑端配置 Bark、ntfy。','muted'),error,save);
+ form.append(this.translated('p',value.available?'通知通道已开启':'尚未配置通知通道,请在第三方通知中配置。','muted'),error,save);
form.onsubmit=async event=>{event.preventDefault();if(!current())return dialog.close();if(save.disabled)return;save.disabled=true;error.textContent='';try{await this.request(this.path('notifications',sid,provider),{requests:fields.requests.value,completion:fields.completion.value});if(current()){dialog.close();this.notify(BridgeI18n.t('聊天提醒设置已保存'));}}catch(failure){if(current())error.textContent=BridgeI18n.t(failure.message);}finally{save.disabled=false;}};
dialog.append(form);
}catch(error){if(current()){loading.textContent=BridgeI18n.t(error.message);loading.className='error';}}
@@ -273,6 +286,6 @@ class DesktopSessionsView {
form.append(this.node('p','muted',BridgeI18n.t(projects.length?'沿用桌面项目的配置':'请先在桌面客户端创建一个项目会话')),error,create);form.onsubmit=async event=>{event.preventDefault();if(generation!==this.generation)return dialog.close();if(!title.value.trim()||message.required&&!message.value.trim()){error.textContent=BridgeI18n.t(message.required?'请填写聊天名称和首条消息':'请输入聊天名称');return;}create.disabled=true;const body={projectKey:project.value,title:title.value.trim()};if(message.required)body.firstMessage=message.value.trim();const result=await this.mutate('create',body,null);if(result?.result?.id&&generation===this.generation){dialog.close();await this.open(result.result.id);}else{error.textContent=BridgeI18n.t('结果待核对,请检查桌面原会话');create.disabled=false;}};dialog.append(form);
}catch(error){if(dialog.open){loading.textContent=BridgeI18n.t(error.message);loading.className='error';}}
}
- relabel(){this.renderConnectionStatus();this.listStamp='';this.requestStamp='';this.search.placeholder=BridgeI18n.t('搜索聊天或项目');this.input.placeholder=BridgeI18n.t('继续这条聊天…');[...this.mode.options].forEach((option,i)=>option.textContent=BridgeI18n.t(['发送新消息','完成后发送','补充当前任务'][i]));this.listMode.options[0].textContent=BridgeI18n.t('最近交互');this.listMode.options[1].textContent=BridgeI18n.t('按项目');this.surface.options[0].textContent=BridgeI18n.t('全部会话');this.renderList();this.attachments.render();this.updateComposer();this.renderMessages(this.lastMessages||[]);this.collapseComposer(this.composerBody.hidden);this.workbench.relabel();if(this.sid)this.renderRequests(this.freshDetail?this.state?.requests||[]:[]);}
+ relabel(){this.renderConnectionStatus();this.listStamp='';this.requestStamp='';this.search.placeholder=BridgeI18n.t('搜索');this.input.placeholder=BridgeI18n.t('继续这条聊天…');[...this.mode.options].forEach((option,i)=>option.textContent=BridgeI18n.t(['发送新消息','完成后发送','补充当前任务'][i]));this.listMode.options[0].textContent=BridgeI18n.t('最近交互');this.listMode.options[1].textContent=BridgeI18n.t('按项目');this.surface.options[0].textContent=BridgeI18n.t('全部会话');this.renderList();this.attachments.render();this.updateComposer();this.renderMessages(this.lastMessages||[]);this.collapseComposer(this.composerBody.hidden);this.workbench.relabel();if(this.sid)this.renderRequests(this.freshDetail?this.state?.requests||[]:[]);}
}
if(typeof module!=='undefined')module.exports={DesktopSessionsView};
diff --git a/web/features/workspace/file-actions.js b/web/features/workspace/file-actions.js
index 63815ef3..ce2196f9 100644
--- a/web/features/workspace/file-actions.js
+++ b/web/features/workspace/file-actions.js
@@ -46,18 +46,18 @@ window.BridgeFileActions = (() => {
const chat=anchor.closest('.chat'),workbench=chat&&workbenches.get(chat);if(!workbench)return;
event.preventDefault();event.stopImmediatePropagation();choose(anchor,workbench);
},true);
- function mountSettings(container,request){
+ function mountSettings(container,request,{ready=()=>document.getElementById('app')?.hidden===false}={}){
if(!container)return;
- const field=node('fieldset',undefined,'appearance-section'),legend=node('legend',t('文件传输')),label=node('label'),input=node('input'),save=node('button',t('保存配置'),'plain'),message=node('p','','muted');
- legend.dataset.i18n='文件传输';input.type='number';input.min='1';input.max='1048576';input.step='1';input.required=true;save.type='button';save.dataset.i18n='保存配置';
+ const field=node('fieldset',undefined,'appearance-section'),legend=node('legend',t('文件传输')),label=node('label'),input=node('input'),save=node('button',t('保存点击下载阈值'),'plain'),message=node('p','','muted');
+ legend.dataset.i18n='文件传输';input.type='number';input.min='1';input.max='1048576';input.step='1';input.required=true;save.type='button';save.dataset.i18n='保存点击下载阈值';
const caption=node('span',t('点击下载阈值(MiB)'));caption.dataset.i18n='点击下载阈值(MiB)';label.append(caption,input);
const hint=node('p',t('超过阈值仅显示定位;文件标签页中长按或右键下载不限此大小。'),'muted');hint.dataset.i18n='超过阈值仅显示定位;文件标签页中长按或右键下载不限此大小。';field.append(legend,label,hint,save,message);container.append(field);
let dirty=false,loading=false;input.oninput=e=>{e.stopPropagation();dirty=true;};input.onchange=e=>e.stopPropagation();
- const read=async()=>{if(dirty||loading||document.hidden)return;loading=true;try{const value=await request();if(!dirty)input.value=value.clickDownloadMiB;}catch(e){message.textContent=t(e.message);}finally{loading=false;}};
+ const read=async()=>{if(dirty||loading||document.hidden||container.hidden||('open' in container&&!container.open)||!ready())return;loading=true;try{const value=await request();if(!dirty){input.value=value.clickDownloadMiB;message.textContent='';}}catch(e){message.textContent=t(e.message);}finally{loading=false;}};
save.onclick=async()=>{if(!input.reportValidity())return;save.disabled=true;const value=Number(input.value);try{await request({clickDownloadMiB:value});if(Number(input.value)===value)dirty=false;message.textContent=t('已保存,立即生效');}catch(e){message.textContent=t(e.message);}finally{save.disabled=false;}};
// Fetch on reveal/focus, not on a timer. Every download reads the latest server policy.
new MutationObserver(()=>{if(!container.hidden&&(!('open' in container)||container.open))read();}).observe(container,{attributes:true,attributeFilter:['hidden','open']});
- window.addEventListener('focus',read);read();return field;
+ window.addEventListener('focus',read);document.addEventListener('bridge-authenticated',read);return field;
}
return {register:(chat,workbench)=>workbenches.set(chat,workbench),bindRow,menu,mountSettings};
})();
diff --git a/web/features/workspace/workbench.css b/web/features/workspace/workbench.css
index 7c696b2a..0b61b216 100644
--- a/web/features/workspace/workbench.css
+++ b/web/features/workspace/workbench.css
@@ -84,11 +84,11 @@
@media(max-height:500px){.wb-terminal-help{display:none}.wb-terminal-head{padding:4px 10px}.wb-terminal-form{padding:4px 10px}.wb-terminal-form textarea{height:40px}}
.wb-agents{position:relative;isolation:isolate;height:100%;display:flex;flex-direction:column;min-height:0;overflow:hidden}.wb-agents>.wb-git-head strong{flex:1}.wb-agents-list{flex:1;min-height:0;overflow:auto}.wb-agent-card{display:grid;width:100%;gap:6px;text-align:left;border:0;border-bottom:1px solid var(--line);padding:14px 12px;background:transparent;color:var(--text)}.wb-agent-card:hover{background:var(--surface)}.wb-agent-card strong{font-size:14px}.wb-agent-card span{font-size:12px}.wb-agent-card small{font-size:11px;color:var(--muted);overflow-wrap:anywhere}.wb-agent-detail{flex:1;min-height:0;overflow:auto;padding:0 12px 8px}.wb-agent-message{padding:12px 0;border-bottom:1px solid var(--line);font-size:13px;overflow-wrap:anywhere}.wb-agent-message>small{color:var(--muted);font-size:11px}.wb-agent-message pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px}.wb-agent-message .message-body{font-size:13px}
.session-list-region{position:relative;flex:1;min-height:0;overflow:hidden;margin:0 -5px}
-.session-list-region>#sessions{height:100%;margin:0;overscroll-behavior-y:contain;transform:translate3d(0,var(--list-pull,0px),0);transition:transform 180ms ease-out}
-.session-list-region>#sessions.list-pulling{transition:none;will-change:transform}
+.session-list-region>.sessions{height:100%;margin:0;overscroll-behavior-y:contain;transform:translate3d(0,var(--list-pull,0px),0);transition:transform 180ms ease-out}
+.session-list-region>.sessions.list-pulling{transition:none;will-change:transform}
.list-sync{position:absolute;z-index:1;top:0;left:50%;max-width:calc(100% - 16px);white-space:nowrap;font-size:11px;min-height:28px;padding:4px 12px;color:var(--muted);background:var(--surface);border:1px solid var(--line);border-radius:999px;opacity:0;pointer-events:none;transform:translate(-50%,-8px);transition:opacity 160ms ease-out,transform 160ms ease-out}
.list-sync.list-sync-visible{opacity:1;pointer-events:auto;transform:translate(-50%,var(--list-hint-y,4px))}
-@media(prefers-reduced-motion:reduce){.session-list-region>#sessions,.list-sync{transition:none}}
+@media(prefers-reduced-motion:reduce){.session-list-region>.sessions,.list-sync{transition:none}}
.wb-pty .wb-terminal-head strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wb-terminal-screen{flex:1;min-height:80px;overflow:hidden;padding:8px 10px 0}.wb-terminal-screen .xterm{height:100%}.wb-terminal-screen .xterm-viewport{overscroll-behavior:contain}.wb-terminal-screen .xterm-helper-textarea{font-size:16px!important}.wb-terminal-keys{display:flex;gap:6px;overflow-x:auto;flex:none;padding:6px 10px;border-top:1px solid #303947}.wb-terminal-keys button{flex:none;min-width:38px;min-height:36px;padding:4px 8px;border:1px solid #384353;border-radius:6px;background:#1b212b;color:#cdd7e5;font:12px ui-monospace,monospace}.wb-terminal-input{display:none;gap:8px;padding:4px 10px;flex:none}.wb-terminal-touch .wb-terminal-input{display:flex}.wb-terminal-input textarea{flex:1;min-width:0;max-height:22dvh;resize:vertical;font:16px/1.45 ui-monospace,monospace;background:#1b212b;color:#e2e8f0;border:1px solid #384353;border-radius:6px;padding:8px}.wb-terminal-input textarea::placeholder{color:#a3afbf}.wb-terminal-input button{flex:none;width:auto;margin:0;padding:8px;border:1px solid #384353;border-radius:6px;background:#1b212b;color:#a7eed0;font-size:12px}.wb-pty .wb-status{font-size:10px}
.wb-side-content{display:flex;flex-direction:column;min-height:0;height:100%;overflow:hidden}.wb-side-head{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);flex-wrap:wrap;flex:none}.wb-side-head strong{margin-right:auto;font-size:13px}.wb-side-head button{min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.wb-side-help{margin:10px 14px 0;color:var(--muted);font-size:12px;line-height:1.6;flex:none}.wb-side-content>.wb-status{margin:8px 14px;flex:none}.wb-side-messages{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:8px 14px}.wb-side-message{margin:0 0 18px;overflow-wrap:anywhere}.wb-side-message>small{display:block;color:var(--muted);font-size:11px;margin-bottom:6px}.wb-side-message.user{background:var(--soft);border-radius:12px;padding:10px 12px}.wb-side-message pre{white-space:pre-wrap;overflow-wrap:anywhere}
diff --git a/web/features/workspace/workbench.js b/web/features/workspace/workbench.js
index fb55c523..c199efcb 100644
--- a/web/features/workspace/workbench.js
+++ b/web/features/workspace/workbench.js
@@ -195,9 +195,11 @@ class Workbench {
if (session.files[index].sideChat && !await session.files[index].sideChat.end()) return;
if (!session.files[index] || session.files[index].id !== id) return;
session.files[index].controller?.abort(); session.files[index].git?.dispose(); session.files[index].terminal?.dispose(); session.files[index].agents?.dispose(); session.files[index].sideChat?.dispose(); session.files.splice(index, 1);
- if(session.splitTab===id)session.splitTab=null;
- if (this.current !== session) { if (session.active === id) session.active = 'chat'; return; }
- if (session.active === id) this.select(session.files.find(t => t.id === 'files') ? 'files' : 'chat'); else this.paint();
+ if(session.splitTab===id)session.splitTab=(session.files[index]||session.files[index-1])?.id||null;
+ const wasActive=session.active===id;
+ if(wasActive)session.active=session.splitTab||(session.files.some(t=>t.id==='files')?'files':'chat');
+ if (this.current !== session) return;
+ if (wasActive) this.select(session.active); else this.paint();
}
files() {
const session = this.current; if (!session) return;
diff --git a/web/index.html b/web/index.html
index c814b2bd..17ecf932 100644
--- a/web/index.html
+++ b/web/index.html
@@ -8,7 +8,7 @@