Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/mobile/lan-mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const PAIRING_TTL_MS = 5 * 60 * 1000

const RPC_ALLOWLIST = new Set([
'workspace.list',
'workspace.archiveSession',
'session.list',
'session.history',
'session.create',
Expand Down
8 changes: 6 additions & 2 deletions src/main/mobile/lan-mobile-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function renderMobilePage({ locale }: MobilePageOptions): string {
header{height:52px;display:flex;flex:none;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 4px}.brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}.brand img{width:35px;height:20px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}.status{width:8px;height:8px;border-radius:50%;background:#35a867;box-shadow:0 0 0 0 rgba(53,168,103,.42);animation:connectedPulse 1.8s ease-out infinite}.status.connecting{background:var(--muted);animation:none}.status.error-state{background:#e34d59;animation:none}@keyframes connectedPulse{70%{box-shadow:0 0 0 7px rgba(53,168,103,0)}100%{box-shadow:0 0 0 0 rgba(53,168,103,0)}}
.view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:7px;padding:12px 0 6px}.session-actions select{flex:1;min-width:0;height:40px}.icon-button{display:grid;place-items:center;width:40px;height:40px;padding:0}.icon-button.refreshing svg{animation:spin .65s linear infinite}.new-session{height:40px;flex:none;font-weight:500;padding:7px 11px;border-radius:10px}.new-session:disabled{cursor:default;opacity:.45}.workspace-hint{color:var(--muted);font-size:12px;line-height:1.4;padding:4px 10px 10px}.workspace-hint[hidden]{display:none}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px}
select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent}
.list{display:grid;align-content:start;gap:2px;overflow-y:auto;padding:2px 0 12px}.row{width:100%;display:flex;align-items:center;gap:12px;text-align:left;background:transparent;border:0;border-radius:9px;padding:11px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;min-width:0;flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row time{flex:none;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums}
.list{display:grid;align-content:start;gap:2px;overflow-y:auto;padding:2px 0 12px}.row{width:100%;display:flex;align-items:center;gap:12px;text-align:left;background:transparent;border:0;border-radius:9px;padding:11px 12px;cursor:pointer}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;min-width:0;flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row time{flex:none;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums}.row .archive-btn{flex:none;width:30px;height:30px;padding:0;display:grid;place-items:center;background:transparent;border:0;border-radius:6px;color:var(--muted);cursor:pointer}.row .archive-btn:hover,.row .archive-btn:focus{background:var(--hover);color:var(--ink)}
.empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:0;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px;margin:8px 0 14px}.message.assistant{align-self:stretch;padding:0 4px;margin:0}.message .role{display:none}
.markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.table-wrap{width:100%;overflow-x:auto;margin:10px 0;-webkit-overflow-scrolling:touch}.markdown table{width:100%;min-width:420px;border-collapse:collapse;font-size:13px;line-height:1.5}.markdown th,.markdown td{padding:9px 10px;border:1px solid var(--line);text-align:left;vertical-align:top}.markdown th{background:var(--hover);font-weight:600}.thinking,.tool{border:1px solid var(--line);border-radius:8px;background:var(--card);margin:2px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:7px 10px;color:var(--muted);font-size:12px;line-height:20px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:9px 10px;color:var(--muted);font-size:12px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate}
.composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.toast{position:fixed;left:50%;top:calc(64px + env(safe-area-inset-top));z-index:5;transform:translate(-50%,-8px);background:var(--ink);color:var(--paper);border-radius:999px;padding:6px 11px;font-size:12px;opacity:0;pointer-events:none;transition:.18s ease}.toast.show{opacity:1;transform:translate(-50%,0)}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.skeleton,.status,.icon-button.refreshing svg{animation:none}}
Expand All @@ -46,6 +46,8 @@ export function renderMobilePage({ locale }: MobilePageOptions): string {
<script>
const L=${JSON.stringify({
noSessions: zh ? '还没有会话。' : 'No sessions yet.',
archiveSession: zh ? '归档会话' : 'Archive session',
archived: zh ? '已归档' : 'Archived',
untitled: zh ? '未命名会话' : 'Untitled session',
failed: zh ? '请求失败' : 'Request failed',
chooseWorkspace: zh ? '请先选择一个工作区。' : 'Choose a workspace first.',
Expand Down Expand Up @@ -77,8 +79,10 @@ function updateRunning(events){let running=false;for(const entry of events){cons
async function loadWorkspaces(){const previous=$('workspace').value,value=await rpc('workspace.list');workspaces=value.items||[];if(workspaces.length){$('workspace').innerHTML=workspaces.map(w=>'<option value="'+esc(w.workspaceId)+'">'+esc(w.title||w.path)+'</option>').join('');$('workspace').value=workspaces.some(w=>w.workspaceId===previous)?previous:workspaces[0].workspaceId}else $('workspace').innerHTML='<option value="">'+L.noWorkspaces+'</option>'}
function syncWorkspaceUi(){const selected=!!$('workspace').value;$('newSession').disabled=!selected;$('workspaceHint').hidden=selected;$('workspaceHint').textContent=selected?'':L.noWorkspaces;if(selected)$('listError').textContent=''}
function relativeTime(value){const parsed=typeof value==='number'?value:Date.parse(value),elapsed=Math.max(0,Date.now()-(Number.isFinite(parsed)?parsed:Date.now())),minutes=Math.floor(elapsed/60000);if(minutes<1)return L.justNow;if(minutes<60)return minutes+L.minute;const hours=Math.floor(minutes/60);if(hours<24)return hours+L.hour;return Math.floor(hours/24)+L.day}
async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):new Set(),archived=new Set(value.archivedSessionIds||[]),items=(value.items||[]).filter(s=>allowed.has(s.sessionId)&&!archived.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=!wid?'':items.length?items.map(s=>{const title=titleFor(s);return'<button class="row" data-id="'+esc(s.sessionId)+'" data-title="'+esc(title)+'"><strong>'+esc(title)+'</strong><time>'+esc(relativeTime(s.updatedAt))+'</time></button>'}).join(''):'<div class="empty">'+L.noSessions+'</div>';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id,b.dataset.title))}catch(e){$('listError').textContent=e.message}}
async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):new Set(),archived=new Set(value.archivedSessionIds||[]),items=(value.items||[]).filter(s=>allowed.has(s.sessionId)&&!archived.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=!wid?'':items.length?items.map(s=>{const title=titleFor(s);return'<div class="row" data-id="'+esc(s.sessionId)+'" data-title="'+esc(title)+'"><strong>'+esc(title)+'</strong><time>'+esc(relativeTime(s.updatedAt))+'</time><button class="archive-btn" data-action="archive" aria-label="'+esc(L.archiveSession)+'" title="'+esc(L.archiveSession)+'"><svg width="16" height="16" viewBox="0 0 20 20" fill="none"><path d="M4 5h12M6 5v10a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V5M8 5V4a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>'}).join(''):'<div class="empty">'+L.noSessions+'</div>';document.querySelectorAll('.row').forEach(r=>{r.onclick=e=>{if(e.target.closest('.archive-btn'))return;openSession(r.dataset.id,r.dataset.title)}});document.querySelectorAll('.archive-btn').forEach(btn=>{btn.onclick=e=>{e.stopPropagation();const row=btn.closest('.row');if(row)archiveSession(row.dataset.id)}})}catch(e){$('listError').textContent=e.message}}
async function createSession(){const workspaceId=$('workspace').value;if(!workspaceId){$('listError').textContent=L.chooseWorkspace;return}try{const created=await rpc('session.create',{workspaceId});await openSession(created.sessionId)}catch(e){$('listError').textContent=e.message}}

async function archiveSession(sessionId){try{await rpc('workspace.archiveSession',{sessionId});await loadSessions();showToast(L.archived)}catch(e){$('listError').textContent=e.message}}
function showToast(message){$('toast').textContent=message;$('toast').classList.add('show');clearTimeout(showToast.timer);showToast.timer=setTimeout(()=>$('toast').classList.remove('show'),1000)}
async function refreshAll(){const button=$('refresh');button.disabled=true;button.classList.add('refreshing');try{await loadWorkspaces();syncWorkspaceUi();await loadSessions();showToast(L.refreshed)}catch(e){$('listError').textContent=e.message}finally{setTimeout(()=>{button.disabled=false;button.classList.remove('refreshing')},350)}}
function titleFor(s){const p=s.projections&&s.projections.values||{};return p.title||p.sessionTitle||p['session.title']||L.untitled}
Expand Down
7 changes: 7 additions & 0 deletions test/lan-mobile-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ describe('LAN mobile bridge pairing surface', () => {
value: { items: [], archivedSessionIds: [] }
})

const archiveForwarded = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, {
method: 'POST',
headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ method: 'workspace.archiveSession', payload: { sessionId: 'test' } })
})
expect(archiveForwarded.status).toBe(200)

const sameBridge = await bridge.start()
expect(sameBridge.port).toBe(snapshot.port)
const stillAuthorized = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, {
Expand Down
6 changes: 6 additions & 0 deletions test/lan-mobile-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@
expect(html).toContain("status.classList.add('error-state')")
expect(html).toContain('archived=new Set(value.archivedSessionIds||[])')
expect(html).toContain('!archived.has(s.sessionId)')
expect(html).toContain('archive-btn')
expect(html).toContain('data-action="archive"')
expect(html).toContain('function archiveSession(')
expect(html).toContain("rpc('workspace.archiveSession'")
expect(html).toContain('归档会话')

Check failure on line 67 in test/lan-mobile-pages.test.ts

View workflow job for this annotation

GitHub Actions / macOS Apple Silicon

test/lan-mobile-pages.test.ts > LAN mobile page > uses the DSH brand color and follows system dark mode

AssertionError: expected '<!doctype html>\n<html lang="en">\n<h…' to contain '归档会话' - Expected + Received - 归档会话 + <!doctype html> + <html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,viewport-fit=cover"> + <meta name="theme-color" content="#f8f8f6"> + <meta name="apple-mobile-web-app-capable" content="yes"> + <meta name="apple-mobile-web-app-title" content="DSH Mobile"> + <link rel="icon" href="/app-icon"> + <link rel="apple-touch-icon" href="/app-icon"> + <title>DSH Mobile</title> + <style> + :root{color-scheme:light;--paper:#fff;--sidebar:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--card:#fff;--hover:#f2f3f5;--brand:#4d6bfe} + @media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#141416;--sidebar:#19191b;--ink:#f5f5f6;--muted:#95979d;--line:#303034;--card:#1d1d20;--hover:#29292d;--brand:#6f86ff}} + *{box-sizing:border-box}html,body{height:100%;overflow:hidden}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} + button,input,textarea,select{font:inherit}.shell{max-width:760px;height:var(--app-height,100dvh);margin:auto;padding:calc(10px + env(safe-area-inset-top)) 14px calc(8px + env(safe-area-inset-bottom));display:flex;flex-direction:column} + header{height:52px;display:flex;flex:none;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 4px}.brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}.brand img{width:35px;height:20px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}.status{width:8px;height:8px;border-radius:50%;background:#35a867;box-shadow:0 0 0 0 rgba(53,168,103,.42);animation:connectedPulse 1.8s ease-out infinite}.status.connecting{background:var(--muted);animation:none}.status.error-state{background:#e34d59;animation:none}@Keyframes connectedPulse{70%{box-shadow:0 0 0 7px rgba(53,168,103,0)}100%{box-shadow:0 0 0 0 rgba(53,168,103,0)}} + .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:7px;padding:12px 0 6px}.session-actions select{flex:1;min-width:0;height:40px}.icon-button{display:grid;place-items:center;width:40px;height:40px;padding:0}.icon-button.refreshing svg{animation:spin .65s linear infinite}.new-session{height:40px;flex:none;font-weight:500;padding:7px 11px;border-radius:10px}.new-session:disabled{cursor:default;opacity:.45}.workspace-hint{color:var(--muted);font-size:12px;line-height:1.4;padding:4px 10px 10px}.workspace-hint[hidden]{display:none}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px} + select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} + .list{display:grid;align-content:start;gap:2px;overflow-y:auto;padding:2px 0 12px}.row{width:100%;display:flex;align-items:center;gap:12px;text-align:left;background:transparent;border:0;border-radius:9px;padding:11px 12px;cursor:pointer}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;min-width:0;flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row time{flex:none;color:var
expect(html).toContain('已归档')
})

it('uses DSH styling on both pairing surfaces', () => {
Expand Down
Loading