From 638fa4b74c716c5e663440bf7b8c9be53f326811 Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 00:22:49 +0800 Subject: [PATCH 01/31] fix(ui): group Claude list filters in the toolbar --- web/features/clients/client-navigation.css | 6 ++++++ web/features/clients/desktop-sessions.js | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css index 4c3efc78..4601042c 100644 --- a/web/features/clients/client-navigation.css +++ b/web/features/clients/client-navigation.css @@ -8,6 +8,12 @@ .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 .archive input{width:14px;height:14px;flex:none;margin:0} +/* Keep search usable; filters wrap with the actual list pane width. */ +.ds-list .chat-list-toolbar{display:flex;flex-wrap:wrap} +.ds-list .chat-list-toolbar>.search{flex:1 1 140px;min-width:140px} +.ds-list .chat-list-toolbar>.list-filters{display:flex;flex:0 1 auto;flex-wrap:wrap;gap:6px;margin-left:auto} +.ds-list .chat-list-toolbar .list-mode{flex:none} +.ds-list .chat-list-toolbar .ds-surface{width:auto;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/desktop-sessions.js b/web/features/clients/desktop-sessions.js index 97071a48..952b0ae6 100644 --- a/web/features/clients/desktop-sessions.js +++ b/web/features/clients/desktop-sessions.js @@ -9,10 +9,10 @@ class DesktopSessionsView { 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.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.connectionInitialize,this.connectionRefresh,this.rowsRoot); 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'); From 2cfb6e243ec2a63968b47ce30a7ab78effb26372 Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 00:22:49 +0800 Subject: [PATCH 02/31] feat(ui): add shared pull refresh for Claude and DSH lists --- .../2026-10-12-shared-client-list-refresh.md | 31 +++++++++++++++++ tests/desktop-sessions-ui.test.cjs | 33 +++++++++++++++++-- tests/list-sync.test.cjs | 8 +++++ web/features/chat/list-sync.js | 17 +++++----- web/features/clients/client-navigation.css | 2 +- web/features/clients/desktop-sessions.js | 32 +++++++++++------- web/features/workspace/workbench.css | 6 ++-- 7 files changed, 104 insertions(+), 25 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md diff --git a/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md new file mode 100644 index 00000000..04b3785f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md @@ -0,0 +1,31 @@ +# Agent Note: Claude 与 DSH 列表的手动下拉刷新 + +Status: implemented + +## Problem + +Claude 与 DSH 只有自动轮询,用户无法像 Codex 一样通过下拉确认列表已更新。原刷新入口吞掉读取错误,不能直接作为手动刷新成功的判据。 + +## Decision + +Claude 与 DSH 将共享 `ListSync` 接到实际 `.sessions` 滚动容器,复用顶部下拉阈值、横向取消及加载、成功、失败重试提示。提示位于独立列表区域,拉动内容不改变滚动区域高度,短列表和空列表也保留触摸区域。 + +`DesktopSessionsView.refreshList()` 只读取列表,手动刷新不触发会话详情读取、恢复、消息或审批。它与自动轮询、前台恢复共用 `sharedRead` 及当前视图的列表处理 Promise;同一轮读取不追加重复请求。列表失败会向手动刷新传递错误,断开连接的响应不显示更新成功。原有自动轮询负责定时及前台恢复,桌面客户端的 `ListSync` 不再单独启动另一套定时器。 + +客户端切换、账号读取失效、登出及页面隐藏重置手势与提示;刷新修订号保证旧请求不能修改新提示或解除新请求的忙碌状态。现有 generation、读取 epoch 继续约束数据结果。渲染使用最新搜索和筛选,保留所选会话、草稿及可保留的列表位置;列表中部优先按首个可见会话锚定,数据插入后仍保持该会话的相对位置,锚点消失时保留可用的滚动偏移。 + +历史决策检索未发现活跃笔记中已有同一 ListSync/手动下拉刷新契约。本记录落实本地 v2 优化评审第 4 项,不引入连接管理或写操作的重试策略。 + +## Alternatives considered + +- 各客户端独立实现手势:能单独调整体验,但会重复阈值、取消与提示逻辑,现有共享组件已覆盖这些行为。 +- 直接调用原轮询入口:能复用现有读取流程,但还会读取已选会话详情且吞掉错误,无法准确反馈手动列表刷新结果。 +- 整页重新加载:容易实现,但会丢失阅读状态并触发无关初始化,不采用。 + +## Testing + +Node 回归覆盖并发读取合并、等待完成、失败与断开连接重试、筛选和草稿保留、客户端与账号失效、旧请求不得解锁新请求。隔离浏览器检查 390/844/1440px 下两个客户端的长列表、短列表、空列表、顶部手势、中部滚动、横向取消及失败重试;使用合成数据,不操作真实账号或任务。 + +## Consequences + +三个客户端共享手势及结果反馈,手动确认不增加写操作或重复轮询。代价是共享组件需要维护可重置的请求修订号,列表读取与轮询编排分开。浏览器验证不代替 Android/iOS 真机 WebView 的回弹体验验收。 diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs index 10c38d3c..8981715c 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,9 +23,9 @@ 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}; } @@ -328,3 +329,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/list-sync.test.cjs b/tests/list-sync.test.cjs index aa6a61f2..d2c87e72 100644 --- a/tests/list-sync.test.cjs +++ b/tests/list-sync.test.cjs @@ -59,3 +59,11 @@ 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,/已更新/); +}); diff --git a/web/features/chat/list-sync.js b/web/features/chat/list-sync.js index 2c89db76..e8ebd14d 100644 --- a/web/features/chat/list-sync.js +++ b/web/features/chat/list-sync.js @@ -1,11 +1,11 @@ 'use strict'; class ListSync { - constructor({root,status,refresh,visible,ready}) { - Object.assign(this,{root,status,refresh,visible,ready});this.busy=false;this.frame=0; + constructor({root,status,refresh,visible,ready,automatic=true}) { + Object.assign(this,{root,status,refresh,visible,ready});this.busy=false;this.frame=0;this.revision=0; status.onclick=()=>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,17 @@ 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.endPull(true);this.setPull(0);clearTimeout(this.hideTimer);clearTimeout(this.dismissTimer);this.status.classList.remove('list-sync-visible');this.status.hidden=true;} 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;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;}if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}} + catch(e){if(revision===this.revision){this.setPull(0);this.show('列表暂未更新,点击重试');}} + finally{if(revision===this.revision)this.busy=false;} } } diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css index 4c3efc78..ee2ede0c 100644 --- a/web/features/clients/client-navigation.css +++ b/web/features/clients/client-navigation.css @@ -1,4 +1,4 @@ -.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}.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} diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js index 97071a48..f9c1265d 100644 --- a/web/features/clients/desktop-sessions.js +++ b/web/features/clients/desktop-sessions.js @@ -30,8 +30,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 +61,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; @@ -95,7 +97,7 @@ 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.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=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(); } @@ -116,16 +118,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=BridgeI18n.t(value.connected===false?'尚未连接':this.rows.length?'已连接桌面':'已连接,桌面还没有会话');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);if(value.connected===false)throw Error(BridgeI18n.t('尚未连接'));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=BridgeI18n.t(error.message);if(this.sid&&!this.state)this.error.textContent=this.listStatus.textContent;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 +143,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(); 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} From f81bfd4621646b03ca5b5be7abbaaabc31e42039 Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 00:22:50 +0800 Subject: [PATCH 03/31] fix(workbench): keep split when closing an active tool tab --- tests/desktop-sessions-ui.test.cjs | 26 ++++++++++++++++++++++++++ web/features/workspace/workbench.js | 8 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs index 10c38d3c..11fb440a 100644 --- a/tests/desktop-sessions-ui.test.cjs +++ b/tests/desktop-sessions-ui.test.cjs @@ -74,6 +74,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'); }); 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; From 3e5ce02bf1f15a4ddc67a88ebb78912d9215f424 Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 02:14:56 +0800 Subject: [PATCH 04/31] Compact client list controls and native remove labels --- .../try2love/codexbridge/MobileStrings.java | 2 +- mobile/ios/BridgePreview/App.swift | 2 +- web/features/clients/client-navigation.css | 23 +++++++++++++++---- web/features/clients/desktop-sessions.js | 2 +- web/index.html | 2 +- web/shared/i18n.js | 6 ++--- web/shell/app.js | 1 + 7 files changed, 26 insertions(+), 12 deletions(-) diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java index 2321811a..542872b7 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java @@ -183,7 +183,7 @@ final class MobileStrings { values.put("当前电脑","Current computer"); values.put("尚未选择电脑","No computer selected"); values.put("返回首页扫码或输入网关地址。","Return home to scan or enter a gateway address."); - values.put("移除这台电脑","Remove this computer"); + values.put("移除这台电脑","Remove"); values.put("此预览尚未配置系统推送项目。当前仅同步收件箱;配置 Firebase 或厂商推送后才能启用后台任务提醒。","System push is not configured. Background task alerts require Firebase or a device vendor’s push service."); values.put("手机通知测试","Mobile notification test"); values.put("本机通知已开启","Local notifications are enabled"); diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift index 2d0ea910..075c01ef 100644 --- a/mobile/ios/BridgePreview/App.swift +++ b/mobile/ios/BridgePreview/App.swift @@ -1215,7 +1215,7 @@ enum MobileStrings { "当前电脑": "Current computer", "尚未选择电脑": "No computer selected", "返回首页扫码或输入网关地址。": "Return home to scan or enter a gateway address.", - "移除这台电脑": "Remove this computer", + "移除这台电脑": "Remove", "此预览尚未配置系统推送项目。当前仅同步收件箱;配置 Firebase 或厂商推送后才能启用后台任务提醒。": "System push is not configured. Background task alerts require Firebase or a device vendor’s push service.", "手机通知测试": "Mobile notification test", "本机通知已开启": "Local notifications are enabled", diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css index 35ce5f5a..561f9cca 100644 --- a/web/features/clients/client-navigation.css +++ b/web/features/clients/client-navigation.css @@ -1,4 +1,4 @@ -.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>.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} +.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} @@ -9,10 +9,9 @@ .chat-list-toolbar .archive{display:flex;align-items:center;gap:3px;font-size:11px;min-height:36px} .chat-list-toolbar .archive input{width:14px;height:14px;flex:none;margin:0} /* Keep search usable; filters wrap with the actual list pane width. */ -.ds-list .chat-list-toolbar{display:flex;flex-wrap:wrap} -.ds-list .chat-list-toolbar>.search{flex:1 1 140px;min-width:140px} -.ds-list .chat-list-toolbar>.list-filters{display:flex;flex:0 1 auto;flex-wrap:wrap;gap:6px;margin-left:auto} -.ds-list .chat-list-toolbar .list-mode{flex:none} +.ds-list .chat-list-toolbar{grid-template-columns:minmax(0,1fr) auto auto auto} +.ds-list .chat-list-toolbar>.list-filters{display:contents} +.ds-list .chat-list-toolbar .list-mode{min-width:0} .ds-list .chat-list-toolbar .ds-surface{width:auto;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} @@ -25,3 +24,17 @@ .client-swipe-rows{flex:1;min-height:0;overflow:hidden!important} .client-swipe-caption{display:flex;align-items:center;gap:8px;flex:none;padding:10px 8px;font-size:12px;font-weight:600;color:var(--muted)} .no-client .chat-list-toolbar{display:none} + +.list-search-toggle{display:none;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1px solid var(--line);border-radius:8px;background:var(--raised);color:var(--text);font-size:24px;cursor:pointer} +#sidebar{container-type:inline-size} +@container (max-width:420px){ + .chat-list-toolbar,.ds-list .chat-list-toolbar{display:flex;flex-wrap:nowrap;gap:5px} + .chat-list-toolbar .list-search-toggle{display:flex;flex:none} + .chat-list-toolbar>.search{display:none;min-width:0;flex:1} + .chat-list-toolbar>.list-filters,.ds-list .chat-list-toolbar>.list-filters{display:contents} + .chat-list-toolbar .list-mode{flex:1;min-width:0} + .chat-list-toolbar .list-mode select{width:100%;max-width:none;min-width:0} + .chat-list-toolbar .archive{flex:none} + .chat-list-toolbar.search-open>.search{display:block} + .chat-list-toolbar.search-open>.list-filters,.ds-list .chat-list-toolbar.search-open>.list-filters{display:none} +} diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js index 786022c2..3c799908 100644 --- a/web/features/clients/desktop-sessions.js +++ b/web/features/clients/desktop-sessions.js @@ -12,7 +12,7 @@ class DesktopSessionsView { 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.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot); + 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');const searchToggle=this.node('button','list-search-toggle','⌕');searchToggle.type='button';this.label(searchToggle,'搜索聊天或项目');searchToggle.setAttribute('aria-expanded','false');searchToggle.onclick=()=>{const open=toolbar.classList.toggle('search-open');searchToggle.setAttribute('aria-expanded',String(open));if(open)this.search.focus();};toolbar.append(searchToggle,this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot); 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'); diff --git a/web/index.html b/web/index.html index c814b2bd..bd58f717 100644 --- a/web/index.html +++ b/web/index.html @@ -8,7 +8,7 @@

任务在电脑运行 · 沿用桌面模型设置
任务在电脑运行 · 沿用桌面模型设置
任务在电脑运行 · 沿用桌面模型设置

账号与接入

模型与推理强度

应用到当前聊天,正在运行的任务从下一轮使用。

新建聊天

选择已有项目,或不选择项目创建独立聊天。电脑会打开这条聊天。

-

PushPlus 通知

使用微信登录 PushPlus、关注服务号并完成实名认证,复制个人资料中的 Token。勾选启用并保存后发送测试通知,再到设置中选择全会话通知,或在聊天提醒中单独调整。

PushPlus 需付费实名认证,最低 3.9 元;认证后可使用基础额度,无需另购会员。费用以官方页面为准。

微信渠道普通实名用户每天 200 次请求、每分钟 5 次;相同内容每小时最多 3 条,失败请求也计入额度。超限可能暂停推送。

PushPlus 官网与 Token ↗ · 实名认证与费用 ↗ · 额度与频率限制 ↗

+

第三方通知

通用通知设置
+

修改聊天名称

聊天通知设置

选择 Skill

选择后随下一条消息一起发送,最多 8 个。

聊天详情

@@ -26,7 +27,7 @@

设置

主页快捷入口 - +
全会话通知 diff --git a/web/shared/i18n.js b/web/shared/i18n.js index 5d838237..2ab7bc2f 100644 --- a/web/shared/i18n.js +++ b/web/shared/i18n.js @@ -2,6 +2,41 @@ // UI messages only. Never translate chat content, commands, model IDs or user input. const BridgeI18n=(()=>{ const en={ +"第三方通知":"Third-party notifications", +"显示第三方通知入口":"Show notification shortcut", +"微信":"WeChat", +"返回":"Back", +"通用通知设置":"Common notification settings", +"保存通用设置":"Save common settings", +"重试读取":"Retry loading", +"读取中":"Loading", +"读取失败":"Could not load", +"未配置":"Not configured", +"已关闭":"Off", +"已开启":"On", +"通用通知设置已保存":"Common notification settings saved", +"安全提醒":"Security alerts", +"入口通知":"Gateway address notifications", +"网关名称":"Gateway name", +"包含聊天标题":"Include chat title", +"通知跳转地址":"Notification link URL", +"在 iPhone 的 Bark 中获取 Device Key,并填写服务地址。":"Get a Device Key in Bark on your iPhone and enter the server URL.", +"在 Android 的 ntfy 中订阅相同服务和主题。公共主题可以留空 Token。":"Subscribe to the same server and topic in ntfy on Android. Public topics can leave the token blank.", +"通过微信获取 PushPlus Token,保存后可以发送测试通知。":"Get a PushPlus token through WeChat. Save it before sending a test notification.", +"启用 Bark":"Enable Bark", +"启用 ntfy":"Enable ntfy", +"启用 PushPlus":"Enable PushPlus", +"清除已保存的 Token":"Clear saved token", +"填写 Bark Device Key":"Enter Bark Device Key", +"填写 ntfy Token":"Enter ntfy token", +"填写 PushPlus Token":"Enter PushPlus token", +"Bark 已接受测试通知,请在手机确认是否收到":"Bark accepted the test notification. Check your phone.", +"ntfy 已接受测试通知,请在手机确认是否收到":"ntfy accepted the test notification. Check your phone.", +"PushPlus 已接受测试通知,请在手机确认是否收到":"PushPlus accepted the test notification. Check your phone.", +"Bark 配置已保存":"Bark settings saved", +"ntfy 配置已保存":"ntfy settings saved", +"PushPlus 配置已保存":"PushPlus settings saved", +"尚未配置通知通道,请在第三方通知中配置。":"No notification channel configured. Set one up in Third-party notifications.", "手机访问地址(可选)":"Phone access URL (optional)", "留空使用服务器公网 IP":"Leave blank to use the server public IP", "使用域名时,需要自行将域名解析到服务器公网 IP,并配置 HTTPS 证书和反向代理;填写完整地址,例如 https://bridge.example.com。":"For a domain, point its DNS record to the server public IP and configure an HTTPS certificate and reverse proxy. Enter the full URL, such as https://bridge.example.com.", diff --git a/web/shell/app.js b/web/shell/app.js index 83dfd4b4..eb7086f1 100644 --- a/web/shell/app.js +++ b/web/shell/app.js @@ -259,7 +259,7 @@ $('notify-button').onclick=async()=>{ $('notify-enabled').value=notificationState.requests||'inherit'; $('notify-completion').value=notificationState.completion||'inherit'; for(const id of ['notify-enabled','notify-completion','notify-save'])$(id).disabled=false; - $('notify-channel-status').textContent=t(notificationState.available?'通知通道已开启':'尚未配置通知通道,可在设置中配置 PushPlus,或在电脑端配置 Bark、ntfy。'); + $('notify-channel-status').textContent=t(notificationState.available?'通知通道已开启':'尚未配置通知通道,请在第三方通知中配置。'); }; $('notify-form').onsubmit=async event=>{ event.preventDefault(); @@ -602,34 +602,127 @@ $('rename-form').onsubmit=async event=>{ }catch(error){$('rename-error').textContent=t(error.message);} finally{renameBusy=false;$('rename-save').disabled=false;} }; -let pushplusBusy=false; -function renderPushplus(config){ - $('pushplus-enabled').checked=!!config.pushplusEnabled; +let notificationChannelBusy=false,selectedNotificationChannel='',notificationListRequest=0,notificationCommonLoaded=false; +const notificationChannelDetails={ + bark:{name:'Bark',enabled:'barkEnabled',server:'barkServer',secret:'barkKey',hasSecret:'hasBarkKey',clear:'clearBarkKey',secretLabel:'Device Key',help:'在 iPhone 的 Bark 中获取 Device Key,并填写服务地址。'}, + ntfy:{name:'ntfy',enabled:'enabled',server:'server',secret:'token',hasSecret:'hasToken',clear:'clearToken',secretLabel:'Token',help:'在 Android 的 ntfy 中订阅相同服务和主题。公共主题可以留空 Token。'}, + pushplus:{name:'PushPlus',enabled:'pushplusEnabled',secret:'pushplusToken',hasSecret:'hasPushplusToken',clear:'clearPushplusToken',secretLabel:'Token',help:'通过微信获取 PushPlus Token,保存后可以发送测试通知。'} +}; +function renderNotificationChannel(config){ + const channel=notificationChannelDetails[selectedNotificationChannel]; + $('pushplus-heading').textContent=channel.name; + $('notification-channel-help').textContent=t(channel.help); + $('pushplus-help').hidden=selectedNotificationChannel!=='pushplus'; + $('notification-channel-enable-label').textContent=t('启用 '+channel.name); + $('pushplus-enabled').checked=!!config[channel.enabled]; + $('notification-server-row').hidden=!channel.server; + $('notification-topic-row').hidden=selectedNotificationChannel!=='ntfy'; + if(channel.server){ + $('notification-server-label').textContent=t(channel.name+' 服务地址'); + $('notification-server').value=config[channel.server]||''; + } + if(selectedNotificationChannel==='ntfy')$('notification-topic').value=config.topic||''; + $('notification-secret-label').textContent=channel.secretLabel; + $('notification-clear-label').textContent=t('清除已保存的 '+channel.secretLabel); $('pushplus-token').value='';$('pushplus-clear').checked=false; - $('pushplus-token').placeholder=t(config.hasPushplusToken?'已保存,留空保留':'填写 PushPlus Token'); + $('pushplus-token').placeholder=t(config[channel.hasSecret]?'已保存,留空保留':'填写 '+channel.name+' '+channel.secretLabel); +} +function renderNotificationCommon(config){ + for(const id of ['security-enabled','address-enabled','include-title'])$('notification-'+id).checked=!!config[{'security-enabled':'securityEnabled','address-enabled':'addressEnabled','include-title':'includeTitle'}[id]]; + $('notification-address-name').value=config.addressName||''; + $('notification-click-base').value=config.clickBase||''; +} +function notificationCommonControls(enabled){ + for(const id of ['security-enabled','address-enabled','address-name','include-title','click-base','common-save'])$('notification-'+id).disabled=!enabled; +} +async function loadNotificationCommon(request){ + notificationCommonLoaded=false;notificationCommonControls(false); + $('notification-common-retry').hidden=true;$('notification-common-error').textContent=''; + try{ + const config=await api('/api/notifications/channels/common'); + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + renderNotificationCommon(config);notificationCommonLoaded=true;notificationCommonControls(true); + }catch(error){ + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + $('notification-common-error').textContent=t(error.message);$('notification-common-retry').hidden=false; + } +} +async function loadNotificationChannelStatuses(request){ + for(const name of Object.keys(notificationChannelDetails))$('notification-status-'+name).textContent=t('读取中'); + const responses=await Promise.allSettled(Object.keys(notificationChannelDetails).map(name=>api('/api/notifications/channels/'+name))); + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + for(const [index,name] of Object.keys(notificationChannelDetails).entries()){ + const result=responses[index],channel=notificationChannelDetails[name]; + const status=result.status==='rejected'?'读取失败':(name==='ntfy'?!!result.value.topic:!!result.value[channel.hasSecret])?(result.value[channel.enabled]?'已开启':'已关闭'):'未配置'; + $('notification-status-'+name).textContent=t(status); + } } $('pushplus-settings').onclick=async()=>{ - if(pushplusBusy)return;pushplusBusy=true;$('pushplus-settings').disabled=true; - try{renderPushplus(await api('/api/notifications/pushplus'));$('pushplus-error').textContent='';$('pushplus-dialog').showModal();} - catch(error){toast(error.message);} - finally{pushplusBusy=false;$('pushplus-settings').disabled=false;} + if(notificationChannelBusy)return; + const request=++notificationListRequest; + $('notification-channels-dialog').showModal(); + await Promise.all([loadNotificationCommon(request),loadNotificationChannelStatuses(request)]); +}; +$('notification-channels-dialog').addEventListener('close',()=>{notificationListRequest++;notificationCommonLoaded=false;notificationCommonControls(false);}); +$('notification-common-retry').onclick=()=>loadNotificationCommon(notificationListRequest); +for(const button of document.querySelectorAll('[data-notification-channel]'))button.onclick=async()=>{ + if(notificationChannelBusy)return; + const channel=button.dataset.notificationChannel; + const request=notificationListRequest; + notificationChannelBusy=true;button.disabled=true; + try{ + const config=await api('/api/notifications/channels/'+channel); + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + selectedNotificationChannel=channel; + renderNotificationChannel(config); + $('pushplus-error').textContent=''; + $('notification-channels-dialog').close(); + $('pushplus-dialog').showModal(); + }catch(error){if(request===notificationListRequest&&$('notification-channels-dialog').open)$('notification-common-error').textContent=t(error.message);} + finally{notificationChannelBusy=false;button.disabled=false;} +}; +$('notification-channel-back').onclick=()=>{$('pushplus-dialog').close();$('pushplus-settings').onclick();}; +$('notification-common-save').onclick=async()=>{ + if(notificationChannelBusy||!notificationCommonLoaded||!$('notification-channels-dialog').open)return; + notificationChannelBusy=true;$('notification-common-save').disabled=true;$('notification-common-error').textContent=''; + const request=notificationListRequest; + try{ + const config=await api('/api/notifications/channels/common',{ + securityEnabled:$('notification-security-enabled').checked, + addressEnabled:$('notification-address-enabled').checked, + addressName:$('notification-address-name').value.trim(), + includeTitle:$('notification-include-title').checked, + clickBase:$('notification-click-base').value.trim() + }); + if(request===notificationListRequest&&$('notification-channels-dialog').open){renderNotificationCommon(config);toast('通用通知设置已保存');} + }catch(error){if(request===notificationListRequest&&$('notification-channels-dialog').open)$('notification-common-error').textContent=t(error.message);} + finally{notificationChannelBusy=false;if(request===notificationListRequest&¬ificationCommonLoaded)notificationCommonControls(true);} }; -async function pushplusAction(test){ - if(pushplusBusy)return;pushplusBusy=true; - for(const id of ['pushplus-save','pushplus-test','pushplus-enabled','pushplus-token','pushplus-clear'])$(id).disabled=true; +async function notificationChannelAction(test){ + if(notificationChannelBusy||!selectedNotificationChannel)return; + notificationChannelBusy=true; + const ids=['pushplus-save','pushplus-test','pushplus-enabled','notification-server','notification-topic','pushplus-token','pushplus-clear']; + for(const id of ids)$(id).disabled=true; $('pushplus-error').textContent=''; + const channel=notificationChannelDetails[selectedNotificationChannel]; try{ + const url='/api/notifications/channels/'+selectedNotificationChannel; if(test){ - await api('/api/notifications/pushplus/test',{});toast('PushPlus 已接受测试通知,请在手机确认是否收到'); + await api(url+'/test',{}); + toast(channel.name+' 已接受测试通知,请在手机确认是否收到'); }else{ - renderPushplus(await api('/api/notifications/pushplus',{pushplusEnabled:$('pushplus-enabled').checked,pushplusToken:$('pushplus-token').value.trim(),clearPushplusToken:$('pushplus-clear').checked})); - toast('PushPlus 配置已保存');if(currentId)await loadNotificationState(currentId,currentHost); + const body={[channel.enabled]:$('pushplus-enabled').checked,[channel.secret]:$('pushplus-token').value.trim(),[channel.clear]:$('pushplus-clear').checked}; + if(channel.server)body[channel.server]=$('notification-server').value.trim(); + if(selectedNotificationChannel==='ntfy')body.topic=$('notification-topic').value.trim(); + renderNotificationChannel(await api(url,body)); + toast(channel.name+' 配置已保存'); + if(currentId)await loadNotificationState(currentId,currentHost); } }catch(error){$('pushplus-error').textContent=t(error.message);} - finally{pushplusBusy=false;for(const id of ['pushplus-save','pushplus-test','pushplus-enabled','pushplus-token','pushplus-clear'])$(id).disabled=false;} + finally{notificationChannelBusy=false;for(const id of ids)$(id).disabled=false;} } -$('pushplus-form').onsubmit=event=>{event.preventDefault();return pushplusAction(false);}; -$('pushplus-test').onclick=()=>pushplusAction(true); +$('pushplus-form').onsubmit=event=>{event.preventDefault();return notificationChannelAction(false);}; +$('pushplus-test').onclick=()=>notificationChannelAction(true); const accountsPanel=window.BridgeSharedRelay||typeof AccountsPanel==='undefined'?null:new AccountsPanel({root:$('accounts-content'),toolbar:$('accounts-heading'),read:()=>api('/api/accounts'),request:({action,...value})=>action==='ignoreSubmission'?ignoreSubmission(value):api(action==='account'?'/api/accounts/account':action==='details'?'/api/accounts/details':action==='reminders'?'/api/accounts/reminders':action==='checkDesktopUpdate'||action==='requestDesktopUpdate'?'/api/accounts/desktop-update':'/api/accounts/switch',action==='checkDesktopUpdate'||action==='requestDesktopUpdate'?{action,...value}:value),onReset:async()=>{$('account-details').open=true;await accountPanel.refresh();$('account-content').querySelector('.account-cards')?.scrollIntoView({block:'nearest'});},onChanged:()=>{accountPanel.clear();resetModelCatalog();},onUpdate:value=>{accountPanel.schedule();const official=value.current?.kind==='chatgpt';$('account-button').hidden=!official;if(!official)$('account-details').open=false;const label='账号管理';$('accounts-button').setAttribute('data-i18n',label);$('accounts-button').textContent=t(label);}}); function openCurrentAccounts(){ diff --git a/web/shell/content.css b/web/shell/content.css index 2fbb175e..d11f5986 100644 --- a/web/shell/content.css +++ b/web/shell/content.css @@ -32,6 +32,7 @@ .phone-header{flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:max(8px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) 8px max(16px,env(safe-area-inset-left));border-bottom:1px solid var(--line)}.phone-header .wordmark{gap:7px;font-size:12px}.phone-header .mark{width:26px;height:26px}.phone-header .light{font-size:9px;letter-spacing:.5px}.phone-language{display:flex;align-items:center;flex:none;font-size:11px;color:var(--muted)}.phone-language select{font:inherit;width:auto;min-height:32px;margin:0;padding:5px;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit}html[lang=en] .compose-bottom{flex-wrap:wrap;gap:8px} .picker .notification-option{display:flex;align-items:center;gap:10px;cursor:pointer}.notification-option input{margin:0;accent-color:var(--green)} +.notification-channel-list{display:grid;gap:8px}.notification-channel-list button{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;width:100%;padding:14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;text-align:left;cursor:pointer}.notification-channel-list button span{color:var(--muted);font-size:12px}.notification-channel-list .notification-channel-status{min-width:48px;text-align:right}.notification-common{border:0;border-top:1px solid var(--line);margin:20px 0 0;padding:16px 0 0}.notification-common legend{font-weight:600}.notification-common label{margin:12px 0}#notification-channel-back{padding-left:0} .message-actions{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.message-actions button{font-size:12px;min-height:32px;padding:5px 8px;color:var(--muted)}.message-actions button:hover{color:var(--accent)}.code-block{position:relative;min-width:0}.code-block>pre{margin-top:0;padding-top:38px}.code-copy{position:absolute;right:5px;top:4px;font-size:12px;padding:3px 8px;min-height:28px}.copy-dialog textarea,#message-action-text{width:100%;box-sizing:border-box;resize:vertical;min-height:180px;max-height:55dvh}.copy-dialog textarea{height:45dvh;font:inherit}.copy-dialog h2{margin-top:0}#message-action-form{display:grid;gap:12px}#message-action-error:empty{display:none}#fork-source{max-width:100%;text-align:left;overflow-wrap:anywhere} From d54c369bb458fa469fd8f7321721c1de6da6bdd7 Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 03:14:55 +0800 Subject: [PATCH 20/31] Keep settings actions readable in one row --- web/features/clients/client-navigation-actions.css | 2 ++ web/features/clients/client-navigation.js | 6 +++--- web/shared/i18n.js | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/web/features/clients/client-navigation-actions.css b/web/features/clients/client-navigation-actions.css index 3b40f626..841c346a 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:repeat(3,minmax(0,1fr));gap:6px;margin:4px 0 16px}.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} diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js index 04f35080..6fab8cd8 100644 --- a/web/features/clients/client-navigation.js +++ b/web/features/clients/client-navigation.js @@ -22,12 +22,12 @@ class ClientNavigation { 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);} 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();});this.switchToggle.after(applications); + const applications=make('应用管理',()=>{settings.close();this.openManager();});applications.dataset.i18nAriaLabel='应用管理';applications.setAttribute('aria-label',BridgeI18n.t('应用管理'));this.settingsNotifications=make('第三方通知',()=>{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. @@ -234,7 +234,7 @@ class ClientNavigation { 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.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.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; diff --git a/web/shared/i18n.js b/web/shared/i18n.js index 2ab7bc2f..a9fc7eb9 100644 --- a/web/shared/i18n.js +++ b/web/shared/i18n.js @@ -156,8 +156,8 @@ const en={ "这会立即结束电脑上的应用,可能中断正在运行的会话并丢失未保存内容。请确认仍要继续。":"This will immediately stop the desktop app. Running sessions may be interrupted and unsaved content may be lost. Confirm to continue.", "确认强制关闭":"Force quit now", "底部应用切换":"Bottom app switcher", -"显示底部 APP 图标":"Show bottom app icons", -"隐藏底部 APP 图标":"Hide bottom app icons", +"显示底部 APP 图标":"Show icons", +"隐藏底部 APP 图标":"Hide icons", "保存点击下载阈值":"Save click download limit", "隐藏图标不关闭应用,仍可在应用管理中打开聊天。":"Hiding an icon does not stop the app. Open its chats from App management.", "查看聊天":"View chats", From a9a937167338cfe13fdfb2cb21701ffbf2686a3c Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 03:22:25 +0800 Subject: [PATCH 21/31] Fit selected filters beside search in 310px sidebar --- tests/chat-list-toolbar.test.cjs | 6 +++--- web/features/clients/client-navigation.css | 6 +++--- web/features/clients/client-navigation.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/chat-list-toolbar.test.cjs b/tests/chat-list-toolbar.test.cjs index da46f951..0ff2f459 100644 --- a/tests/chat-list-toolbar.test.cjs +++ b/tests/chat-list-toolbar.test.cjs @@ -10,7 +10,7 @@ test('list filters follow the selected label and language',()=>{ 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,'84px');assert.equal(surface.style.width,'60px'); - mode.selectedOptions[0].textContent='Project';events.change();assert.equal(mode.style.width,'92px'); - mode.selectedOptions[0].textContent='按项目';documentEvents['bridge-language']();assert.equal(mode.style.width,'60px'); + 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/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css index 2319aa0a..437a30ae 100644 --- a/web/features/clients/client-navigation.css +++ b/web/features/clients/client-navigation.css @@ -1,8 +1,8 @@ .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:flex;flex-wrap:wrap;gap:6px;align-items:center;flex:none;margin:4px 0 8px;min-width:0} -.chat-list-toolbar>.search{flex:1 1 96px;width:100%;min-width:96px;height:36px;padding:7px 8px;margin:0} -.chat-list-toolbar>.list-filters{display:flex;flex:none;flex-wrap:wrap;gap:6px;max-width:100%;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;font-size:12px} diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js index 3f5e2506..cba553ec 100644 --- a/web/features/clients/client-navigation.js +++ b/web/features/clients/client-navigation.js @@ -11,7 +11,7 @@ function bindChatListToolbar(toolbar){ 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+36)+'px'; + select.style.width=Math.ceil(measure.getBoundingClientRect().width+30)+'px'; } }; const later=()=>requestAnimationFrame(update); From ffbb283a1e3e1c140ffc05c2bd947838031c96cb Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 03:22:32 +0800 Subject: [PATCH 22/31] Fit notification actions and footer labels on phones --- web/features/clients/client-navigation-actions.css | 2 +- web/features/clients/client-navigation.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/features/clients/client-navigation-actions.css b/web/features/clients/client-navigation-actions.css index 841c346a..6ac81931 100644 --- a/web/features/clients/client-navigation-actions.css +++ b/web/features/clients/client-navigation-actions.css @@ -8,4 +8,4 @@ .ds-messages article{border:0}.client-functions #computers-button{color:var(--accent)} -.settings-action-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:4px 0 16px}.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} +.settings-action-row{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,.6fr) minmax(0,.9fr);gap:6px;margin:4px 0 16px}.settings-action-row.single-client{grid-template-columns:repeat(2,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} diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js index 6fab8cd8..bcc920ef 100644 --- a/web/features/clients/client-navigation.js +++ b/web/features/clients/client-navigation.js @@ -10,7 +10,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); @@ -233,8 +233,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.settingsNotifications.textContent=BridgeI18n.language()==='en'?'Push alerts':'第三方通知'; + 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.settingsActions.classList.toggle('single-client',this.switchToggle.hidden);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; From 0e64cf9f29c871b1b70d92edd1c9d9675699943c Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Tue, 13 Oct 2026 03:24:22 +0800 Subject: [PATCH 23/31] Shorten visible chat search placeholder --- tests/desktop-sessions-ui.test.cjs | 10 ++++++++++ web/features/clients/desktop-sessions.js | 4 ++-- web/index.html | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs index 374887cb..e7f8f1a8 100644 --- a/tests/desktop-sessions-ui.test.cjs +++ b/tests/desktop-sessions-ui.test.cjs @@ -31,6 +31,16 @@ function fixture(){ } 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'); diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js index e1d1de5a..1bb8206d 100644 --- a/web/features/clients/desktop-sessions.js +++ b/web/features/clients/desktop-sessions.js @@ -6,7 +6,7 @@ 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();filters.prepend(this.surface); @@ -286,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/index.html b/web/index.html index c814b2bd..2bd907c4 100644 --- a/web/index.html +++ b/web/index.html @@ -8,7 +8,7 @@

任务在电脑运行 · 沿用桌面模型设置
-

开源 · 自托管 · V2.0 正式版

+

开源 · 自托管 · v2.0.3 正式版

你的工作台,
不止在电脑前。

Codex、Claude Desktop、DeepSeek Harness 各司其职,你在手机上接着聊、查文件、看 Git、用终端。浏览器随开随用,Android 与 iOS App 随身接力。

代码开源,欢迎共建。觉得有用,欢迎前往 GitHub 点个 Star,也欢迎反馈问题、贡献代码。

任务仍在你的电脑或 SSH 主机上执行,执行端需保持在线。

2.0STABLE

从远程聊天,
到随身工作台。

WebAndroidiOS
-

01 / TRY IT

拿起这台手机,试试看。

不必安装,先走一遍你的工作流程。点击设备里的按钮,也可以直接选择下方场景。

模拟交互 · 无需登录
MOBILE WORKSPACE

    iOS APP · V2.0.0
    直接体验

    工作台与电脑网关使用 v2.0.0 产品界面和示例数据,手机首页为浏览器模拟。聊天回复、客户端状态、终端输出和 Git 操作均在本页模拟;不连接电脑、不调用模型、不读取或上传你的文件,刷新即重置。

    THREE CLIENTS / ONE BRIDGE

    三个应用,各自继续自己的工作。

    扫描电脑中已安装的客户端,只启用需要的应用。底部切换或左右滑动聊天列表,各自的会话与项目互不混淆。

    Codex

    Codex

    继续本机与 SSH 会话,保留模型、Skill、权限、计划和目标操作。官方账号与兼容 API 沿用原桌面工作环境。

    Claude

    Claude Desktop

    Code 与 Cowork 分组展示,继续消息并回应支持的授权。自动接入支持 macOS、Windows;首次连接可能需要解锁电脑完成准备。

    DeepSeek Harness

    DeepSeek Harness

    发现已安装的 Harness,沿用账号与工作目录,在同一个网关查看可用模型、推理选项及客户端提供的额度。

    02 / THE WORKBENCH

    不只是把聊天装进手机。

    v2.0.0 将聊天、文件与工具连成完整工作台。手机 Web、Android、iOS 和电脑浏览器共用会话,按屏幕宽度呈现。

    01 / FILES

    文件跟着项目走。

    打开目录、搜索文件,预览文本、Markdown 与图片。聊天附件可选择定位或下载,下载浮窗显示进度与速度;图片支持双指、滚轮缩放和拖动。

    聊天下载上限可即时调整;文件标签页的主动下载不受此上限限制。

    02 / TERMINAL

    不是单次命令,是连续终端。

    保留 cd 后的目录、环境变量和交互状态。采用用户实际登录 shell:macOS 通常为 zsh,Linux 通常为 bash,Windows 使用 COMSPEC。手机换行用于编写,点击发送才执行。

    切换标签保留会话,关闭终端标签结束会话。

    03 / GIT

    看见变更,也能完成提交。

    从增删差异到历史链路,在手机上审阅并暂存文件、填写提交说明。支持创建或切换本地分支、本地合并与中止合并。

    不提供远程推送、历史重写或强制丢弃。

    04 / SIDE CHAT

    Codex 侧边聊天,另开一条思路。

    继承创建时的主聊天上下文。模型、思考程度、Skill、权限和附件一个不少;支持普通/计划模式、排队和补充任务。同一网关的多台设备共享这条临时会话。

    结束或重启网关后清除;不进入原生侧边栏,暂不支持 SSH 工作区。

    05 / SPLIT VIEW

    宽屏,真正用起来。

    主聊天在左,文件、Git、终端或侧边聊天在右。拖动标签开启分屏,拖动分隔条调整比例;详情窗口可调节高度。窄屏和手机保持单栏。

    会话区域宽度至少 720 px 时提供分屏;以实际可用空间为准。

    06 / SUBAGENTS

    Codex 子智能体,进度看得见。

    在任务树里查看当前会话派生的子智能体,展开最近已保存的输入、回复和工具记录,不必离开主聊天。

    当前为只读记录,不提供创建、发消息或停止子智能体。

    看看 v2.0.0 的分屏界面
    真实 Web 界面 · 示例数据;上方可点击设备为交互示意。

    03 / MADE FOR YOUR PHONE

    一个 App,接上你的几台电脑。

    扫码保存 Mac、Windows 或 Ubuntu 网关,回到电脑列表随时切换。应用图标显示未读角标,点击直达对应应用。

    01

    连接与登录

    保存多台电脑及登录状态;电脑端可撤销设备。Web 支持记住登录,密码更新或凭证撤销后需重新验证。

    02

    真正顺手的操作

    点击直接复制,图片预览有关闭入口,附件调用系统保存。聊天列表支持下拉刷新,Android 首页连续两次返回可退出。

    03

    完整的输入区

    选择模型、思考程度、Skill 与权限,上传附件,普通/计划模式自由切换。Codex 主聊天保留目标模式,侧边聊天支持独立设置;其他客户端按自身能力提供选项。

    04

    外观、语言与更新

    中英文界面切换,聊天内容保持原样。设置里调整显示、图片缩略图,主动检查 App 更新;不会自动下载安装。

    下载手机 App ↗Android APK · iOS IPA(需自行签名)

    STAY IN THE LOOP

    收到提醒,也知道它的边界。

    App 前台可同步任务提醒,通知可清空,点击可回到对应电脑和聊天。外部 Bark、ntfy、PushPlus 通道继续保留,支持的通知链接可跳回已保存的电脑。

    iOS 灵动岛与锁屏实时活动

    查看执行、待回应和连接状态,点击回到聊天。不显示聊天正文;App 不活跃时可能显示最后已知状态。

    标准手机 App 不保证后台或锁屏通知送达,灵动岛也不等于后台实时推送。

    04 / GET THE APPS

    准备好,把演示变成你的工作台。

    v2.0.2 · 网关与手机 App 配套使用

    01

    先安装电脑网关

    macOS、Windows,以及实验性 Ubuntu x64 / ARM64。Web 工作台由网关提供,浏览器打开地址即可。

    电脑端下载 ↗
    02

    Android

    下载 APK,按系统提示安装。已有预览版可覆盖安装,保留已保存的电脑。

    下载 Android APK ↗

    手机 App 请搭配 v2.0.2 网关使用。升级前保留配置备份,相同端口只运行一个网关;iOS 仍需使用自己的 Apple 账号签名安装。

    基础操作录像 · v1.3.0 界面(展开观看)
    +

    01 / TRY IT

    拿起这台手机,试试看。

    不必安装,先走一遍你的工作流程。点击设备里的按钮,也可以直接选择下方场景。

    模拟交互 · 无需登录
    MOBILE WORKSPACE

      iOS APP · V2.0.0
      直接体验

      工作台与电脑网关使用 v2.0.0 产品界面和示例数据,手机首页为浏览器模拟。聊天回复、客户端状态、终端输出和 Git 操作均在本页模拟;不连接电脑、不调用模型、不读取或上传你的文件,刷新即重置。

      THREE CLIENTS / ONE BRIDGE

      三个应用,各自继续自己的工作。

      扫描电脑中已安装的客户端,只启用需要的应用。底部切换或左右滑动聊天列表,各自的会话与项目互不混淆。

      Codex

      Codex

      继续本机与 SSH 会话,保留模型、Skill、权限、计划和目标操作。官方账号与兼容 API 沿用原桌面工作环境。

      Claude

      Claude Desktop

      Code 与 Cowork 分组展示,继续消息并回应支持的授权。自动接入支持 macOS、Windows;首次连接可能需要解锁电脑完成准备。

      DeepSeek Harness

      DeepSeek Harness

      发现已安装的 Harness,沿用账号与工作目录,在同一个网关查看可用模型、推理选项及客户端提供的额度。

      02 / THE WORKBENCH

      不只是把聊天装进手机。

      v2.0.0 将聊天、文件与工具连成完整工作台。手机 Web、Android、iOS 和电脑浏览器共用会话,按屏幕宽度呈现。

      01 / FILES

      文件跟着项目走。

      打开目录、搜索文件,预览文本、Markdown 与图片。聊天附件可选择定位或下载,下载浮窗显示进度与速度;图片支持双指、滚轮缩放和拖动。

      聊天下载上限可即时调整;文件标签页的主动下载不受此上限限制。

      02 / TERMINAL

      不是单次命令,是连续终端。

      保留 cd 后的目录、环境变量和交互状态。采用用户实际登录 shell:macOS 通常为 zsh,Linux 通常为 bash,Windows 使用 COMSPEC。手机换行用于编写,点击发送才执行。

      切换标签保留会话,关闭终端标签结束会话。

      03 / GIT

      看见变更,也能完成提交。

      从增删差异到历史链路,在手机上审阅并暂存文件、填写提交说明。支持创建或切换本地分支、本地合并与中止合并。

      不提供远程推送、历史重写或强制丢弃。

      04 / SIDE CHAT

      Codex 侧边聊天,另开一条思路。

      继承创建时的主聊天上下文。模型、思考程度、Skill、权限和附件一个不少;支持普通/计划模式、排队和补充任务。同一网关的多台设备共享这条临时会话。

      结束或重启网关后清除;不进入原生侧边栏,暂不支持 SSH 工作区。

      05 / SPLIT VIEW

      宽屏,真正用起来。

      主聊天在左,文件、Git、终端或侧边聊天在右。拖动标签开启分屏,拖动分隔条调整比例;详情窗口可调节高度。窄屏和手机保持单栏。

      会话区域宽度至少 720 px 时提供分屏;以实际可用空间为准。

      06 / SUBAGENTS

      Codex 子智能体,进度看得见。

      在任务树里查看当前会话派生的子智能体,展开最近已保存的输入、回复和工具记录,不必离开主聊天。

      当前为只读记录,不提供创建、发消息或停止子智能体。

      看看 v2.0.0 的分屏界面
      真实 Web 界面 · 示例数据;上方可点击设备为交互示意。

      03 / MADE FOR YOUR PHONE

      一个 App,接上你的几台电脑。

      扫码保存 Mac、Windows 或 Ubuntu 网关,回到电脑列表随时切换。应用图标显示未读角标,点击直达对应应用。

      01

      连接与登录

      保存多台电脑及登录状态;电脑端可撤销设备。Web 支持记住登录,密码更新或凭证撤销后需重新验证。

      02

      真正顺手的操作

      点击直接复制,图片预览有关闭入口,附件调用系统保存。聊天列表支持下拉刷新,Android 首页连续两次返回可退出。

      03

      完整的输入区

      选择模型、思考程度、Skill 与权限,上传附件,普通/计划模式自由切换。Codex 主聊天保留目标模式,侧边聊天支持独立设置;其他客户端按自身能力提供选项。

      04

      外观、语言与更新

      中英文界面切换,聊天内容保持原样。设置里调整显示、图片缩略图,主动检查 App 更新;不会自动下载安装。

      下载手机 App ↗Android APK · iOS IPA(需自行签名)

      STAY IN THE LOOP

      收到提醒,也知道它的边界。

      App 前台可同步任务提醒。直连网关时,可在手机或 Web 的「第三方通知」选择 Bark(iPhone)、ntfy(Android)或 PushPlus(微信),配置并测试。

      iOS 灵动岛与锁屏实时活动

      查看执行、待回应和连接状态,点击回到聊天。不显示聊天正文;App 不活跃时可能显示最后已知状态。

      标准手机 App 不保证后台或锁屏通知送达,灵动岛也不等于后台实时推送。

      04 / GET THE APPS

      准备好,把演示变成你的工作台。

      v2.0.3 正式版 · 网关与手机 App 配套更新

      01

      先安装电脑网关

      macOS、Windows,以及实验性 Ubuntu x64 / ARM64。Web 工作台由网关提供,浏览器打开地址即可。

      电脑端下载 ↗
      02

      Android

      覆盖安装 APK 以保留已保存的电脑。v2.0.3 支持应用内更新,可选择手机下载或电脑下载,已下载的包可继续安装。

      下载 Android APK ↗

      手机 App 请搭配 v2.0.3 网关使用。升级前保留配置备份,相同端口只运行一个网关;iOS 仍需使用自己的 Apple 账号签名安装。

      基础操作录像 · v1.3.0 界面(展开观看)

      01 / WALKTHROUGH

      先看看,它是怎么用的。

      从启动网关到跨设备继续聊天,跟着实际界面走一遍。

      -

      Tailscale 配置图解 →查看正式版源码

      +

      Tailscale 配置图解 →查看正式版源码

      -

      06 / DOWNLOAD

      让手边的设备,接上工作。

      v2.0.2 · 当前正式版

      -

      v2.0.2 改善文件链接、手机文件打开、登录自启,以及聊天列表滚动与下拉刷新。

      +

      06 / DOWNLOAD

      让手边的设备,接上工作。

      v2.0.3 · 正式版

      +

      v2.0.3 新增 Android 应用内更新、手机与 Web 第三方通知配置,改善搜索筛选、横屏布局和连接恢复。

      安装包内置网关运行时。按电脑的系统与芯片下载。

      -

      从 v1.4.0 升级:先备份配置,在“应用与维护 → 应用更新”检查更新,或安装新版安装包;完成后刷新网页并更新手机 App。Ubuntu 使用安装包手动升级。

      +

      建议网关与手机 App 一起更新,网页重新加载。旧 Android 可先覆盖安装 APK,v2.0.3 起后续更新可在 App 内完成;iOS 仍需自行签名,Ubuntu 使用安装包手动升级。

      访问端需要登录同一个 OpenAI 账号吗? @@ -454,7 +454,7 @@

      如果它让你的 Codex