@chatcode/cco-better-sidebar 0.18.0-rc.4 → 0.18.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -865,13 +865,124 @@ function registerBundleRoute(ctx, fence) {
865
865
  });
866
866
  }
867
867
  //#endregion
868
+ //#region src/login-page-script.ts
869
+ /** Standalone sign-in recovery; each document starts once and always keeps a usable reopen action. */
870
+ const CHATCODE_LOGIN_PAGE_SCRIPT = String.raw`
871
+ (() => {
872
+ const message = document.getElementById('message');
873
+ const button = document.getElementById('login');
874
+ const fallback = document.getElementById('login-fallback');
875
+ const link = document.getElementById('login-link');
876
+ const current = new URL(location.href);
877
+ const returnTo = (() => {
878
+ const value = current.searchParams.get('return');
879
+ if (!value || !value.startsWith('/') || value.startsWith('//')) return '/';
880
+ const target = new URL(value, location.origin);
881
+ return target.origin === location.origin ? target.pathname + target.search + target.hash : '/';
882
+ })();
883
+ let busy = false, checking = false, attempted = false, recheck = false, attempt, popup;
884
+ const call = async name => {
885
+ const response = await fetch('/sidebar/api/' + name, {
886
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
887
+ });
888
+ const data = await response.json();
889
+ if (!response.ok || !data.ok) throw new Error('操作失败');
890
+ return data.value;
891
+ };
892
+ const showMessage = (text, error = false) => {
893
+ message.textContent = text;
894
+ message.classList.toggle('error', error);
895
+ };
896
+ async function login() {
897
+ if (busy) return;
898
+ attempted = true;
899
+ busy = true;
900
+ button.disabled = true;
901
+ button.textContent = '正在打开登录页…';
902
+ let tab = null;
903
+ try { tab = window.open('', '_blank'); if (tab) tab.opener = null; } catch {}
904
+ try {
905
+ if (!attempt) {
906
+ const started = await call('chatcode.start');
907
+ const url = new URL(started.url);
908
+ if (!['http:', 'https:'].includes(url.protocol) || typeof started.sessionId !== 'string') throw new Error('登录地址无效');
909
+ attempt = { sessionId: started.sessionId, url: url.href };
910
+ }
911
+ link.href = attempt.url;
912
+ link.textContent = attempt.url;
913
+ fallback.hidden = false;
914
+ if (tab && !tab.closed) {
915
+ try { tab.location.href = attempt.url; popup = tab; } catch { try { tab.close(); } catch {} }
916
+ }
917
+ showMessage('请在登录页完成登录;若网页已关闭,可随时重新打开。');
918
+ } catch {
919
+ try { if (tab) tab.close(); } catch {}
920
+ showMessage('登录页打开失败,请重试。', true);
921
+ } finally {
922
+ busy = false;
923
+ button.disabled = false;
924
+ button.textContent = attempt ? '重新打开登录页' : '去登录';
925
+ }
926
+ }
927
+ async function status() {
928
+ if (checking || busy) return;
929
+ checking = true;
930
+ const observedAttempt = attempt, observedStarted = attempted;
931
+ try {
932
+ const state = await call('chatcode.status');
933
+ if (busy || attempt !== observedAttempt || attempted !== observedStarted) return;
934
+ if (state.available && state.validation === 'valid' && !state.expired) {
935
+ try { if (popup) popup.close(); window.focus(); } catch {}
936
+ location.replace(returnTo);
937
+ return;
938
+ }
939
+ if (!state.available) {
940
+ recheck = true;
941
+ button.textContent = '重新检测';
942
+ showMessage('登录服务暂不可用,请稍后重试。', true);
943
+ return;
944
+ }
945
+ if (state.configured && state.validation === 'unavailable') {
946
+ recheck = true;
947
+ button.textContent = '重新检测';
948
+ showMessage('暂时无法验证已有账号,请检查网络后重新检测。', true);
949
+ return;
950
+ }
951
+ recheck = false;
952
+ button.textContent = attempt ? '重新打开登录页' : '去登录';
953
+ if (attempt && (state.login?.sessionId !== attempt.sessionId || state.login.state !== 'pending')) {
954
+ attempt = undefined;
955
+ fallback.hidden = true;
956
+ link.removeAttribute('href');
957
+ link.textContent = '';
958
+ button.textContent = '去登录';
959
+ showMessage('登录请求已失效,请重新登录。');
960
+ }
961
+ if (!attempted) await login();
962
+ } catch {
963
+ recheck = true;
964
+ button.textContent = '重新检测';
965
+ showMessage('无法读取登录状态,请稍后重试。', true);
966
+ } finally {
967
+ checking = false;
968
+ if (!busy) button.disabled = false;
969
+ }
970
+ }
971
+ button.addEventListener('click', () => { void (recheck ? status() : login()); });
972
+ void status();
973
+ setInterval(() => { void status(); }, 2000);
974
+ })();
975
+ `;
976
+ //#endregion
868
977
  //#region src/login-route.ts
869
978
  const assetPath = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "logo.svg");
870
- function page(logo) {
979
+ /** Render the standalone login gate with one automatic attempt and persistent manual recovery. */
980
+ function renderChatcodeLoginPage(logo) {
871
981
  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>请登录 ChatCode</title><style>
872
982
  *{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;color:#172b4d;background:radial-gradient(circle at 18% 18%,#e5edff 0,transparent 30rem),linear-gradient(135deg,#edf2fb,#dfe8f7)}.card{width:min(490px,calc(100vw - 40px));padding:34px 44px 36px;border:1px solid #dce4ef;border-radius:18px;background:#fff;box-shadow:0 20px 48px rgba(36,62,103,.18),0 2px 8px rgba(36,62,103,.06)}.brand{display:flex;align-items:center;justify-content:center;gap:12px;padding-bottom:27px;border-bottom:1px solid #edf0f5}.brand img{width:34px;height:34px}.brand strong{font-size:22px;letter-spacing:-.02em}.content{padding-top:40px;text-align:center}.content h1{margin:0;font-size:28px;line-height:1.3;font-weight:650}.content p{min-height:24px;margin:12px 0 30px;color:#71809a;font-size:14px;line-height:1.7}.action{width:100%;min-height:46px;border:0;border-radius:8px;background:#9B0B09;color:#fff;font:600 15px/1 Inter,"Microsoft YaHei",sans-serif;cursor:pointer;box-shadow:0 8px 18px rgba(155,11,9,.25);transition:transform .18s ease,box-shadow .18s ease,background .18s ease}.action:hover:not(:disabled){transform:translateY(-2px);background:#b20d0a;box-shadow:0 12px 24px rgba(155,11,9,.38)}.action:focus-visible{outline:3px solid #f0b7b5;outline-offset:3px}.action:disabled{cursor:default;opacity:.7}.error{color:#b42318}@media(max-width:480px){.card{padding:30px 26px}.content{padding-top:32px}}
873
- </style></head><body><main class="card"><div class="brand"><img src="data:image/svg+xml;base64,${logo}" alt=""><strong>ChatCode</strong></div><section class="content"><h1>请登录 ChatCode</h1><p id="message">请登录后继续使用。</p><button id="login" class="action" type="button">去登录</button></section></main><script>
874
- const message=document.getElementById('message'),button=document.getElementById('login');const current=new URL(location.href);const returnTo=(()=>{const v=current.searchParams.get('return');return v&&v.startsWith('/')&&!v.startsWith('//')?v:'/'} )();const call=async(name,body={})=>{const r=await fetch('/sidebar/api/'+name,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error?.message||'操作失败');return d.value};let waiting=false;async function status(){try{const s=await call('chatcode.status');if(s.available&&s.validation==='valid'&&!s.expired){location.replace(returnTo);return}if(waiting)message.textContent='正在等待浏览器完成登录…'}catch{message.textContent='无法读取登录状态,请稍后重试。';message.classList.add('error')}}async function login(){const tab=window.open('','_blank');button.disabled=true;button.textContent='正在打开登录页…';message.classList.remove('error');try{const s=await call('chatcode.start');waiting=true;if(tab&&!tab.closed){tab.opener=null;tab.location.href=s.url;message.textContent='请在新打开的浏览器窗口完成登录…'}else{message.innerHTML='浏览器拦截了登录窗口,请 <a href="'+s.url+'" target="_blank" rel="noopener noreferrer">打开登录页</a> 后完成登录。'}}catch{try{tab&&tab.close()}catch{}button.disabled=false;button.textContent='去登录';message.textContent='登录页打开失败,请重试。';message.classList.add('error')}}button.addEventListener('click',login);status();setInterval(status,2000);
983
+ .login-fallback{margin:18px 0 0;color:#71809a;font-size:12px;line-height:1.7;overflow-wrap:anywhere}.login-fallback a{display:block;margin-top:4px;color:#315da8;text-underline-offset:3px}
984
+ </style></head><body><main class="card"><div class="brand"><img src="data:image/svg+xml;base64,${logo}" alt=""><strong>ChatCode</strong></div><section class="content"><h1>请登录 ChatCode</h1><p id="message" aria-live="polite">正在检查登录状态…</p><button id="login" class="action" type="button" disabled>正在检查登录状态…</button><div id="login-fallback" class="login-fallback" hidden>若登录网页已关闭或未弹出,可再次访问:<a id="login-link" target="_blank" rel="noopener noreferrer"></a></div></section></main><script>
985
+ ${CHATCODE_LOGIN_PAGE_SCRIPT}
875
986
  <\/script></body></html>`;
876
987
  }
877
988
  function registerLoginRoute(ctx, fence) {
@@ -896,7 +1007,7 @@ function registerLoginRoute(ctx, fence) {
896
1007
  "cache-control": "no-store",
897
1008
  "content-security-policy": "default-src 'self'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"
898
1009
  });
899
- res.end(req.method === "HEAD" ? void 0 : page(logo));
1010
+ res.end(req.method === "HEAD" ? void 0 : renderChatcodeLoginPage(logo));
900
1011
  } catch {
901
1012
  res.writeHead(500);
902
1013
  res.end("login page unavailable");
@@ -0,0 +1,5 @@
1
+ /** Native modal supplies an inert background and default Cancel focus until a choice is made. */
2
+ export declare function LogoutConfirmDialog({ onCancel, onConfirm }: {
3
+ onCancel: () => void;
4
+ onConfirm: () => void;
5
+ }): JSX.Element;
@@ -45,6 +45,8 @@ export declare const zh: {
45
45
  pluginMarketUninstalling: string;
46
46
  accountMenuSettings: string;
47
47
  accountMenuLogout: string;
48
+ accountMenuLogoutConfirmTitle: string;
49
+ accountMenuLogoutConfirmDescription: string;
48
50
  accountMenuLabel: string;
49
51
  updateMenu: string;
50
52
  updateNav: string;
@@ -40,6 +40,6 @@ export declare function SidebarProjectLogo({ size }: {
40
40
  }): ReactNode;
41
41
  /** Expanded-sidebar wordmark supplied through the host's dedicated slot. */
42
42
  export declare function SidebarProjectName(): ReactNode;
43
- export declare const BRANDING_CSS = "\n/* ChatCode CLI branding is deliberately limited to primary actions. Every\n other surface and interaction state (including workspace/sidebar rows,\n menus, the composer, text and shadows) remains owned by the host theme. */\nbody:not([data-ds-dark-theme]) .dut-unicom-primary-action{background:linear-gradient(135deg,#9B0B09,#b71916) !important;border-color:#9B0B09 !important;color:#fff !important;box-shadow:0 7px 18px rgba(155,11,9,.24) !important}\nbody:not([data-ds-dark-theme]) .dut-unicom-primary-action:hover{background:linear-gradient(135deg,#b71916,#cf302c) !important;border-color:#b71916 !important;color:#fff !important;box-shadow:0 10px 24px rgba(155,11,9,.30) !important}\nbody:not([data-ds-dark-theme]) .dut-unicom-primary-action:active{transform:translateY(1px);box-shadow:0 4px 11px rgba(155,11,9,.22) !important}\n/* In dark mode, alter only the resting background of primary buttons. Once\n hovered, the host owns the complete interaction treatment again. */\nbody[data-ds-dark-theme] .dut-unicom-primary-action:not(:hover){background:#9B0B09 !important}\n.dut-project-logo{display:block;object-fit:contain}\n.dut-left-logo{width:44px !important;height:44px !important}\n.dut-brand-title{font-size:18px !important;font-weight:600 !important}\n.dut-sidebar-logo{width:44px !important;height:44px !important}\n/* The alpha sidebar keeps its slotted identity at 24px and clips the brand\n button. Give only our expanded identity the full artwork height so the\n 44px knot is not cropped by that host-owned overflow boundary. */\n[class*=\"brandIdentity\"]:has(.dut-sidebar-logo){height:44px !important}\n.dut-sidebar-brand-name{font-size:18px !important;line-height:22px !important;font-weight:600 !important;letter-spacing:0 !important;white-space:nowrap}\n.dut-brand-version{display:none !important}\n.dut-hero-preview{display:none !important}\n.dut-hero-logo{width:60px !important;height:60px !important}\n.dut-hero-title{font-size:30px !important;line-height:40px !important;font-weight:600 !important;letter-spacing:.06em !important;white-space:nowrap}\n[class*=\"headline\"]:has(.dut-hero-logo){grid-template-columns:60px auto !important;font-size:30px !important;line-height:40px !important}\n.dut-settings-logo{width:19px;height:19px;object-fit:contain}\n";
43
+ export declare const BRANDING_CSS = "\n/* ChatCode CLI branding is deliberately limited to primary actions. Every\n other surface and interaction state (including workspace/sidebar rows,\n menus, the composer, text and shadows) remains owned by the host theme. */\nbody:not([data-ds-dark-theme]) .dut-unicom-primary-action{background:linear-gradient(135deg,#9B0B09,#b71916) !important;border-color:#9B0B09 !important;color:#fff !important;box-shadow:0 7px 18px rgba(155,11,9,.24) !important}\nbody:not([data-ds-dark-theme]) .dut-unicom-primary-action:hover{background:linear-gradient(135deg,#b71916,#cf302c) !important;border-color:#b71916 !important;color:#fff !important;box-shadow:0 10px 24px rgba(155,11,9,.30) !important}\nbody:not([data-ds-dark-theme]) .dut-unicom-primary-action:active{transform:translateY(1px);box-shadow:0 4px 11px rgba(155,11,9,.22) !important}\n/* In dark mode, alter only the resting background of primary buttons. Once\n hovered, the host owns the complete interaction treatment again. */\nbody[data-ds-dark-theme] .dut-unicom-primary-action:not(:hover){background:#9B0B09 !important}\n.dut-project-logo{display:block;object-fit:contain}\n.dut-left-logo{width:24px !important;height:24px !important}\n.dut-brand-title{font:var(--dsw-font-s-strong-14) !important}\n.dut-sidebar-logo{width:24px !important;height:24px !important}\n.dut-sidebar-brand-name{font:var(--dsw-font-s-strong-14) !important;letter-spacing:-.01em !important;white-space:nowrap}\n\n.dut-sidebar-header{height:44px !important;padding:0 4px !important;margin-bottom:8px !important;gap:8px !important}\n.dut-sidebar-brand{min-width:0 !important;background:transparent !important;box-shadow:none !important}\n.dut-sidebar-identity{height:24px !important;gap:8px !important}\n.dut-sidebar-wordmark{font:var(--dsw-font-s-strong-14) !important;letter-spacing:-.01em !important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.dut-sidebar-toggle{width:28px !important;height:28px !important;border-radius:8px !important;color:var(--dsw-alias-label-secondary) !important}\n.dut-sidebar-toggle:hover{background:var(--dsw-alias-interactive-bg-hover) !important}\n/* Reuse the original ChatCode primary-action red, scoped to this marked CTA. */\n.dut-sidebar-new-session{height:40px !important;justify-content:flex-start !important;gap:8px !important;margin:0 0 10px !important;padding:8px 10px !important;border:1px solid #9B0B09 !important;border-radius:10px !important;background:linear-gradient(135deg,#9B0B09,#b71916) !important;color:#fff !important;font:var(--dsw-font-s-strong-14) !important;font-weight:600 !important;box-shadow:none !important;transform:none !important}\n.dut-sidebar-new-session:hover{background:linear-gradient(135deg,#b71916,#cf302c) !important}\n.dut-sidebar-new-session:active{background:#9B0B09 !important}\n.dut-sidebar-new-session:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}\n.dut-sidebar-new-session > span:first-child{flex:1;min-width:0;overflow:hidden;mask-image:none !important}\n.dut-sidebar-new-session > span:first-child > span{width:auto !important;justify-content:flex-start !important;gap:8px !important;min-width:0}\n.dut-sidebar-new-session > span:first-child > span > svg{width:16px;height:16px;flex:none;color:inherit}\n.dut-sidebar-new-session > span:first-child > span > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.dut-sidebar-new-session > span + span{opacity:1 !important;flex:none;color:color-mix(in srgb,currentColor 75%,transparent);font:var(--dsw-font-xxs-12)}\n/* Temporary navigation-only hide. Remove this block to restore Plugins;\n registrations, settings, installed plugins and other panel entries stay intact. */\nnav:is([aria-label=\"\u5168\u5C40\u9762\u677F\"],[aria-label=\"Global panels\"]) > button:is([aria-label=\"\u63D2\u4EF6\"],[aria-label=\"Plugins\"]),\nnav:is([aria-label=\"\u5168\u5C40\u9762\u677F\"],[aria-label=\"Global panels\"]):has(> button:is([aria-label=\"\u63D2\u4EF6\"],[aria-label=\"Plugins\"]):only-child){display:none !important}\n\n.dut-brand-version{display:none !important}\n.dut-hero-preview{display:none !important}\n.dut-hero-logo{width:60px !important;height:60px !important}\n.dut-hero-title{font-size:30px !important;line-height:40px !important;font-weight:600 !important;letter-spacing:.06em !important;white-space:nowrap}\n[class*=\"headline\"]:has(.dut-hero-logo){grid-template-columns:60px auto !important;font-size:30px !important;line-height:40px !important}\n.dut-settings-logo{width:19px;height:19px;object-fit:contain}\n";
44
44
  /** Install the brand theme and retain it across host navigation/rerenders. */
45
45
  export declare function installBranding(): () => void;
@@ -0,0 +1,6 @@
1
+ /** Compact sidebar chrome; the host retains every button, shortcut and drag surface. */
2
+ export declare const SIDEBAR_HEADER_CSS = "\n.dut-sidebar-header{height:44px !important;padding:0 4px !important;margin-bottom:8px !important;gap:8px !important}\n.dut-sidebar-brand{min-width:0 !important;background:transparent !important;box-shadow:none !important}\n.dut-sidebar-identity{height:24px !important;gap:8px !important}\n.dut-sidebar-wordmark{font:var(--dsw-font-s-strong-14) !important;letter-spacing:-.01em !important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.dut-sidebar-toggle{width:28px !important;height:28px !important;border-radius:8px !important;color:var(--dsw-alias-label-secondary) !important}\n.dut-sidebar-toggle:hover{background:var(--dsw-alias-interactive-bg-hover) !important}\n/* Reuse the original ChatCode primary-action red, scoped to this marked CTA. */\n.dut-sidebar-new-session{height:40px !important;justify-content:flex-start !important;gap:8px !important;margin:0 0 10px !important;padding:8px 10px !important;border:1px solid #9B0B09 !important;border-radius:10px !important;background:linear-gradient(135deg,#9B0B09,#b71916) !important;color:#fff !important;font:var(--dsw-font-s-strong-14) !important;font-weight:600 !important;box-shadow:none !important;transform:none !important}\n.dut-sidebar-new-session:hover{background:linear-gradient(135deg,#b71916,#cf302c) !important}\n.dut-sidebar-new-session:active{background:#9B0B09 !important}\n.dut-sidebar-new-session:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}\n.dut-sidebar-new-session > span:first-child{flex:1;min-width:0;overflow:hidden;mask-image:none !important}\n.dut-sidebar-new-session > span:first-child > span{width:auto !important;justify-content:flex-start !important;gap:8px !important;min-width:0}\n.dut-sidebar-new-session > span:first-child > span > svg{width:16px;height:16px;flex:none;color:inherit}\n.dut-sidebar-new-session > span:first-child > span > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.dut-sidebar-new-session > span + span{opacity:1 !important;flex:none;color:color-mix(in srgb,currentColor 75%,transparent);font:var(--dsw-font-xxs-12)}\n/* Temporary navigation-only hide. Remove this block to restore Plugins;\n registrations, settings, installed plugins and other panel entries stay intact. */\nnav:is([aria-label=\"\u5168\u5C40\u9762\u677F\"],[aria-label=\"Global panels\"]) > button:is([aria-label=\"\u63D2\u4EF6\"],[aria-label=\"Plugins\"]),\nnav:is([aria-label=\"\u5168\u5C40\u9762\u677F\"],[aria-label=\"Global panels\"]):has(> button:is([aria-label=\"\u63D2\u4EF6\"],[aria-label=\"Plugins\"]):only-child){display:none !important}\n";
3
+ /** Mark only the expanded brand row and its adjacent native New Session button. */
4
+ export declare function syncSidebarHeader(): void;
5
+ /** Restore native header styling when the branding effect is disposed. */
6
+ export declare function clearSidebarHeader(): void;
@@ -0,0 +1,2 @@
1
+ /** Standalone sign-in recovery; each document starts once and always keeps a usable reopen action. */
2
+ export declare const CHATCODE_LOGIN_PAGE_SCRIPT: string;
@@ -1,2 +1,4 @@
1
1
  import type { Context, SidebarHttpRequest } from './context-types.ts';
2
+ /** Render the standalone login gate with one automatic attempt and persistent manual recovery. */
3
+ export declare function renderChatcodeLoginPage(logo: string): string;
2
4
  export declare function registerLoginRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatcode/cco-better-sidebar",
3
- "version": "0.18.0-rc.4",
3
+ "version": "0.18.0-rc.5",
4
4
  "description": "ChatCode CLI Web plugin: a VS Code-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from 'react'
11
11
  import { api } from './api.ts'
12
12
  import { useChatcodeAccount } from './useChatcodeAccount.ts'
13
13
  import { redirectToChatcodeLogin } from './chatcode-login-route.ts'
14
+ import { LogoutConfirmDialog } from './LogoutConfirmDialog.tsx'
14
15
  import { t } from './locales.ts'
15
16
  import css from './sidebar.module.css'
16
17
 
@@ -75,6 +76,7 @@ function logout(): void {
75
76
  export function AccountMenu(): JSX.Element {
76
77
  const rootRef = useRef<HTMLDivElement>(null)
77
78
  const [open, setOpen] = useState(false)
79
+ const [logoutTarget, setLogoutTarget] = useState<'chatcode' | 'browser'>()
78
80
  const [userName, setUserName] = useState('User')
79
81
  const { chatcode, busy, error, loginUrl, startLogin, logout: logoutChatcode } = useChatcodeAccount()
80
82
 
@@ -157,11 +159,11 @@ export function AccountMenu(): JSX.Element {
157
159
  {canStartChatcodeLogin && <button type="button" role="menuitem" className={css.accountItem} disabled={busy} onClick={startLogin}>
158
160
  <LogoutIcon />{busy ? t('accountMenuChatcodeStarting') : t('accountMenuChatcodeLogin')}
159
161
  </button>}
160
- {hasChatcodeAccount && <button type="button" role="menuitem" className={css.accountItem} disabled={busy} onClick={logoutChatcode}>
162
+ {hasChatcodeAccount && <button type="button" role="menuitem" className={css.accountItem} disabled={busy} onClick={() => { setLogoutTarget('chatcode') }}>
161
163
  <LogoutIcon />{t('accountMenuLogout')}
162
164
  </button>}
163
165
  </>}
164
- {chatcode?.available === false && <button type="button" role="menuitem" className={css.accountItem} onClick={logout}>
166
+ {chatcode?.available === false && <button type="button" role="menuitem" className={css.accountItem} onClick={() => { setLogoutTarget('browser') }}>
165
167
  <LogoutIcon />{t('accountMenuLogout')}
166
168
  </button>}
167
169
  </div>
@@ -178,6 +180,14 @@ export function AccountMenu(): JSX.Element {
178
180
  <span className={css.accountName}>{chatcodeName}</span>
179
181
  <span className={css.accountPresence} style={{ opacity: signedIn ? 1 : 0.25 }} aria-hidden="true" />
180
182
  </button>
183
+ {logoutTarget !== undefined && <LogoutConfirmDialog
184
+ onCancel={() => { setLogoutTarget(undefined) }}
185
+ onConfirm={() => {
186
+ setLogoutTarget(undefined)
187
+ if (logoutTarget === 'chatcode') logoutChatcode()
188
+ else logout()
189
+ }}
190
+ />}
181
191
  </div>
182
192
  )
183
193
  }
@@ -0,0 +1,39 @@
1
+ import { useEffect, useId, useRef } from 'react'
2
+ import { t } from './locales.ts'
3
+ import css from './sidebar.module.css'
4
+
5
+ /** Native modal supplies an inert background and default Cancel focus until a choice is made. */
6
+ export function LogoutConfirmDialog({ onCancel, onConfirm }: {
7
+ onCancel: () => void
8
+ onConfirm: () => void
9
+ }): JSX.Element {
10
+ const ref = useRef<HTMLDialogElement>(null)
11
+ const titleId = useId()
12
+ const descriptionId = useId()
13
+
14
+ useEffect(() => {
15
+ const dialog = ref.current
16
+ dialog?.showModal()
17
+ return () => { if (dialog?.open) dialog.close() }
18
+ }, [])
19
+
20
+ return (
21
+ <dialog
22
+ ref={ref}
23
+ className={css.accountLogoutDialog}
24
+ role="alertdialog"
25
+ aria-modal="true"
26
+ aria-labelledby={titleId}
27
+ aria-describedby={descriptionId}
28
+ onCancel={event => { event.preventDefault(); onCancel() }}
29
+ onKeyDown={event => { event.stopPropagation() }}
30
+ >
31
+ <h2 id={titleId} className={css.accountLogoutTitle}>{t('accountMenuLogoutConfirmTitle')}</h2>
32
+ <p id={descriptionId} className={css.accountLogoutDescription}>{t('accountMenuLogoutConfirmDescription')}</p>
33
+ <div className={css.accountLogoutActions}>
34
+ <button type="button" autoFocus onClick={onCancel}>{t('cancel')}</button>
35
+ <button type="button" className={css.accountLogoutConfirm} onClick={onConfirm}>{t('accountMenuLogout')}</button>
36
+ </div>
37
+ </dialog>
38
+ )
39
+ }
@@ -39,6 +39,8 @@ export const ja: Record<string, string> = {
39
39
  pluginMarketUninstalling: 'アンインストール中…',
40
40
  accountMenuSettings: '設定',
41
41
  accountMenuLogout: 'ログアウト',
42
+ accountMenuLogoutConfirmTitle: 'ログアウトしますか?',
43
+ accountMenuLogoutConfirmDescription: 'ChatCode CLI からログアウトしますか?続行するには再度ログインする必要があります。',
42
44
  accountMenuLabel: 'アカウントメニュー',
43
45
  updateMenu: '更新を確認 / 今すぐ更新',
44
46
  updateNav: '更新',
@@ -46,6 +46,8 @@ export const zh = {
46
46
  pluginMarketUninstalling: '卸载中…',
47
47
  accountMenuSettings: '设置',
48
48
  accountMenuLogout: '退出登录',
49
+ accountMenuLogoutConfirmTitle: '退出登录?',
50
+ accountMenuLogoutConfirmDescription: '确定要退出 ChatCode CLI 吗?退出后需要重新登录才能继续使用。',
49
51
  accountMenuLabel: '账户菜单',
50
52
  updateMenu: '检查更新 / 一键更新',
51
53
  updateNav: '更新',
@@ -538,6 +540,8 @@ export const en: Record<keyof typeof zh, string> = {
538
540
  pluginMarketUninstalling: 'Uninstalling…',
539
541
  accountMenuSettings: 'Settings',
540
542
  accountMenuLogout: 'Log out',
543
+ accountMenuLogoutConfirmTitle: 'Log out?',
544
+ accountMenuLogoutConfirmDescription: 'Are you sure you want to log out of ChatCode CLI? You will need to sign in again to continue.',
541
545
  accountMenuLabel: 'Account menu',
542
546
  updateMenu: 'Check for updates / Update now',
543
547
  updateNav: 'Update',
@@ -1037,6 +1041,8 @@ import { zhMO as zhMODict } from './locales-zh-MO.ts'
1037
1041
  // English fallback for the new account/model surfaces until third-language translations are supplied.
1038
1042
  // Keep this list explicit so the dictionary parity check still catches other missing keys.
1039
1043
  const accountAndModelFallback = {
1044
+ accountMenuLogoutConfirmTitle: en.accountMenuLogoutConfirmTitle,
1045
+ accountMenuLogoutConfirmDescription: en.accountMenuLogoutConfirmDescription,
1040
1046
  chatcodeGateChecking: en.chatcodeGateChecking,
1041
1047
  chatcodeGateUnverified: en.chatcodeGateUnverified,
1042
1048
  chatcodeGatePending: en.chatcodeGatePending,
@@ -442,13 +442,11 @@
442
442
  box-sizing: border-box;
443
443
  gap: 8px;
444
444
  width: 100%;
445
- min-height: 52px;
446
- padding: 10px 14px max(10px, env(safe-area-inset-bottom));
445
+ min-height: 44px;
446
+ padding: 8px 12px max(8px, env(safe-area-inset-bottom));
447
447
  border-radius: 0;
448
448
  background: var(--dsw-alias-bg-layer-1);
449
- font-family: var(--dsw-font-family-body);
450
- font-size: var(--dsw-font-size-body-sm);
451
- line-height: var(--dsw-font-line-height-body-sm);
449
+ font: var(--dsw-font-xs-13);
452
450
  transition: background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
453
451
  }
454
452
 
@@ -466,18 +464,21 @@
466
464
  .accountAvatar {
467
465
  position: relative;
468
466
  display: grid;
469
- width: 28px;
470
- height: 28px;
467
+ box-sizing: border-box;
468
+ width: 24px;
469
+ height: 24px;
471
470
  flex: none;
472
471
  place-items: center;
473
472
  border-radius: 50%;
474
473
  background: var(--dsw-alias-bg-layer-3);
475
474
  border: 1px solid var(--dsw-alias-border-l2);
476
475
  color: var(--dsw-alias-fg-accent);
476
+ font: var(--dsw-font-xxs-12);
477
477
  font-weight: 600;
478
478
  }
479
479
 
480
480
  .accountName {
481
+ min-width: 0;
481
482
  overflow: hidden;
482
483
  text-overflow: ellipsis;
483
484
  white-space: nowrap;
@@ -490,10 +491,10 @@
490
491
  left: 8px;
491
492
  display: grid;
492
493
  box-sizing: border-box;
493
- width: min(252px, calc(100% - 16px));
494
- padding: 10px 8px 8px;
494
+ width: min(224px, calc(100% - 16px));
495
+ padding: 8px 6px 6px;
495
496
  border: 1px solid var(--dsw-alias-border-l2);
496
- border-radius: 12px;
497
+ border-radius: 10px;
497
498
  background: var(--dsw-alias-bg-layer-2);
498
499
  z-index: 1;
499
500
  }
@@ -502,16 +503,15 @@
502
503
  display: flex;
503
504
  align-items: center;
504
505
  gap: 8px;
505
- min-height: 36px;
506
- padding: 0 8px 8px;
507
- font-family: var(--dsw-font-family-body);
508
- font-size: var(--dsw-font-size-body-sm);
509
- line-height: var(--dsw-font-line-height-body-sm);
506
+ min-width: 0;
507
+ min-height: 28px;
508
+ padding: 0 6px 6px;
509
+ font: var(--dsw-font-xs-13);
510
510
  }
511
511
 
512
512
  .accountMenuDivider {
513
513
  height: 1px;
514
- margin: 0 4px 6px;
514
+ margin: 0 4px 4px;
515
515
  background: var(--dsw-alias-border-l2);
516
516
  }
517
517
 
@@ -519,12 +519,12 @@
519
519
  display: flex;
520
520
  align-items: center;
521
521
  gap: 8px;
522
- min-height: 32px;
523
- padding: 0 10px;
522
+ min-height: 30px;
523
+ padding: 0 8px;
524
524
  border-radius: 7px;
525
525
  background: transparent;
526
526
  text-align: left;
527
- font: var(--dsw-font-s-14);
527
+ font: var(--dsw-font-xs-13);
528
528
  }
529
529
 
530
530
  .accountItem:hover {
@@ -546,6 +546,68 @@
546
546
  border-color: var(--dsw-alias-bg-layer-1);
547
547
  }
548
548
 
549
+ /* The native top layer is viewport-centered even though the account rail is positioned. */
550
+ .accountLogoutDialog {
551
+ box-sizing: border-box;
552
+ width: min(400px, calc(100vw - 32px));
553
+ max-width: calc(100vw - 32px);
554
+ max-height: calc(100dvh - 32px);
555
+ margin: auto;
556
+ padding: 24px;
557
+ overflow: auto;
558
+ border: 1px solid var(--dsw-alias-border-l2);
559
+ border-radius: 16px;
560
+ background: var(--dsw-alias-bg-layer-2);
561
+ color: var(--dsw-alias-fg-base);
562
+ font: var(--dsw-font-s-14);
563
+ }
564
+
565
+ .accountLogoutDialog::backdrop {
566
+ background: var(--dsw-alias-bg-mask-1);
567
+ }
568
+
569
+ .accountLogoutTitle {
570
+ margin: 0;
571
+ font: var(--dsw-font-base-strong-16);
572
+ }
573
+
574
+ .accountLogoutDescription {
575
+ margin: 12px 0 24px;
576
+ color: var(--dsw-alias-label-secondary);
577
+ }
578
+
579
+ .accountLogoutActions {
580
+ display: flex;
581
+ flex-wrap: wrap;
582
+ justify-content: flex-end;
583
+ gap: 8px;
584
+ }
585
+
586
+ .accountLogoutActions button {
587
+ min-height: 34px;
588
+ padding: 6px 14px;
589
+ border: 1px solid var(--dsw-alias-border-l2);
590
+ border-radius: 8px;
591
+ background: var(--dsw-alias-bg-layer-2);
592
+ color: var(--dsw-alias-fg-base);
593
+ font: var(--dsw-font-xs-13);
594
+ cursor: pointer;
595
+ }
596
+
597
+ .accountLogoutActions button:hover {
598
+ background: var(--dsw-alias-bg-hover);
599
+ }
600
+
601
+ .accountLogoutActions button:focus-visible {
602
+ outline: 2px solid var(--dsw-alias-border-accent);
603
+ outline-offset: 2px;
604
+ }
605
+
606
+ .accountLogoutActions .accountLogoutConfirm {
607
+ border-color: var(--dsw-alias-state-error-primary);
608
+ color: var(--dsw-alias-state-error-primary);
609
+ }
610
+
549
611
  /* The compatibility action cluster used when the host exposes no native
550
612
  right-sidebar affordance. It stays pinned to the viewport corner, sitting
551
613
  flush in the 34px tab strip (28px buttons at top: 3) whose right end it
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ReactNode } from 'react'
11
+ import { clearSidebarHeader, SIDEBAR_HEADER_CSS, syncSidebarHeader } from './sidebar-header.ts'
11
12
 
12
13
  declare module '@deepseek-ai/dsh-client-ui-slots' {
13
14
  interface SlotMap {
@@ -89,14 +90,11 @@ body:not([data-ds-dark-theme]) .dut-unicom-primary-action:active{transform:trans
89
90
  hovered, the host owns the complete interaction treatment again. */
90
91
  body[data-ds-dark-theme] .dut-unicom-primary-action:not(:hover){background:#9B0B09 !important}
91
92
  .dut-project-logo{display:block;object-fit:contain}
92
- .dut-left-logo{width:44px !important;height:44px !important}
93
- .dut-brand-title{font-size:18px !important;font-weight:600 !important}
94
- .dut-sidebar-logo{width:44px !important;height:44px !important}
95
- /* The alpha sidebar keeps its slotted identity at 24px and clips the brand
96
- button. Give only our expanded identity the full artwork height so the
97
- 44px knot is not cropped by that host-owned overflow boundary. */
98
- [class*="brandIdentity"]:has(.dut-sidebar-logo){height:44px !important}
99
- .dut-sidebar-brand-name{font-size:18px !important;line-height:22px !important;font-weight:600 !important;letter-spacing:0 !important;white-space:nowrap}
93
+ .dut-left-logo{width:24px !important;height:24px !important}
94
+ .dut-brand-title{font:var(--dsw-font-s-strong-14) !important}
95
+ .dut-sidebar-logo{width:24px !important;height:24px !important}
96
+ .dut-sidebar-brand-name{font:var(--dsw-font-s-strong-14) !important;letter-spacing:-.01em !important;white-space:nowrap}
97
+ ${SIDEBAR_HEADER_CSS}
100
98
  .dut-brand-version{display:none !important}
101
99
  .dut-hero-preview{display:none !important}
102
100
  .dut-hero-logo{width:60px !important;height:60px !important}
@@ -110,7 +108,8 @@ function hasPrimaryLabel(button: HTMLButtonElement): boolean {
110
108
  // descriptions, where words like "install" do not identify a primary action.
111
109
  if (button.closest('[data-chatcode-plugin-market], [data-plugin-module]')) return false
112
110
  const label = `${button.textContent ?? ''} ${button.getAttribute('aria-label') ?? ''} ${button.title}`.trim().toLowerCase()
113
- return /(?:^|\s)(new session|send|install)(?:\s|$)|新会话|发送|(?:^|\s)安装(?:\s|$)/u.test(label)
111
+ if (button.classList.contains('dut-sidebar-brand') || button.classList.contains('dut-sidebar-new-session')) return false
112
+ return /(?:^|\s)(send|install)(?:\s|$)|发送|(?:^|\s)安装(?:\s|$)/u.test(label)
114
113
  }
115
114
 
116
115
  /** Replace one host SVG while retaining its generated layout class names. */
@@ -241,6 +240,7 @@ function syncBranding(): void {
241
240
  if (svg instanceof SVGElement) replaceHostLogo(svg)
242
241
  })
243
242
 
243
+ syncSidebarHeader()
244
244
  document.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
245
245
  button.classList.toggle('dut-unicom-primary-action', hasPrimaryLabel(button))
246
246
  })
@@ -285,6 +285,7 @@ export function installBranding(): () => void {
285
285
  return () => {
286
286
  observer.disconnect()
287
287
  if (frame !== undefined) window.cancelAnimationFrame(frame)
288
+ clearSidebarHeader()
288
289
  favicon.remove()
289
290
  style.remove()
290
291
  }
@@ -0,0 +1,73 @@
1
+ /** Compact sidebar chrome; the host retains every button, shortcut and drag surface. */
2
+ export const SIDEBAR_HEADER_CSS = `
3
+ .dut-sidebar-header{height:44px !important;padding:0 4px !important;margin-bottom:8px !important;gap:8px !important}
4
+ .dut-sidebar-brand{min-width:0 !important;background:transparent !important;box-shadow:none !important}
5
+ .dut-sidebar-identity{height:24px !important;gap:8px !important}
6
+ .dut-sidebar-wordmark{font:var(--dsw-font-s-strong-14) !important;letter-spacing:-.01em !important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
7
+ .dut-sidebar-toggle{width:28px !important;height:28px !important;border-radius:8px !important;color:var(--dsw-alias-label-secondary) !important}
8
+ .dut-sidebar-toggle:hover{background:var(--dsw-alias-interactive-bg-hover) !important}
9
+ /* Reuse the original ChatCode primary-action red, scoped to this marked CTA. */
10
+ .dut-sidebar-new-session{height:40px !important;justify-content:flex-start !important;gap:8px !important;margin:0 0 10px !important;padding:8px 10px !important;border:1px solid #9B0B09 !important;border-radius:10px !important;background:linear-gradient(135deg,#9B0B09,#b71916) !important;color:#fff !important;font:var(--dsw-font-s-strong-14) !important;font-weight:600 !important;box-shadow:none !important;transform:none !important}
11
+ .dut-sidebar-new-session:hover{background:linear-gradient(135deg,#b71916,#cf302c) !important}
12
+ .dut-sidebar-new-session:active{background:#9B0B09 !important}
13
+ .dut-sidebar-new-session:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
14
+ .dut-sidebar-new-session > span:first-child{flex:1;min-width:0;overflow:hidden;mask-image:none !important}
15
+ .dut-sidebar-new-session > span:first-child > span{width:auto !important;justify-content:flex-start !important;gap:8px !important;min-width:0}
16
+ .dut-sidebar-new-session > span:first-child > span > svg{width:16px;height:16px;flex:none;color:inherit}
17
+ .dut-sidebar-new-session > span:first-child > span > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
18
+ .dut-sidebar-new-session > span + span{opacity:1 !important;flex:none;color:color-mix(in srgb,currentColor 75%,transparent);font:var(--dsw-font-xxs-12)}
19
+ /* Temporary navigation-only hide. Remove this block to restore Plugins;
20
+ registrations, settings, installed plugins and other panel entries stay intact. */
21
+ nav:is([aria-label="全局面板"],[aria-label="Global panels"]) > button:is([aria-label="插件"],[aria-label="Plugins"]),
22
+ nav:is([aria-label="全局面板"],[aria-label="Global panels"]):has(> button:is([aria-label="插件"],[aria-label="Plugins"]):only-child){display:none !important}
23
+ `
24
+
25
+ const HEADER_CLASSES = [
26
+ 'dut-sidebar-header', 'dut-sidebar-brand', 'dut-sidebar-identity',
27
+ 'dut-sidebar-wordmark', 'dut-sidebar-toggle', 'dut-sidebar-new-session',
28
+ ] as const
29
+ type HeaderClass = typeof HEADER_CLASSES[number]
30
+
31
+ /** Mark only the expanded brand row and its adjacent native New Session button. */
32
+ export function syncSidebarHeader(): void {
33
+ const desired = new Map<HTMLElement, Set<HeaderClass>>()
34
+ const mark = (element: Element | null | undefined, className: HeaderClass): void => {
35
+ if (!(element instanceof HTMLElement)) return
36
+ const classes = desired.get(element) ?? new Set<HeaderClass>()
37
+ classes.add(className)
38
+ desired.set(element, classes)
39
+ }
40
+ for (const name of document.querySelectorAll<HTMLElement>('.dut-sidebar-brand-name,.dut-brand-title')) {
41
+ const row = name.closest<HTMLElement>('[data-window-drag]')
42
+ const logo = row?.querySelector('.dut-sidebar-logo,.dut-left-logo')
43
+ if (!row || !logo) continue
44
+ const brand = [...row.children].find(child => child.contains(name) && child.contains(logo))
45
+ if (!brand) continue
46
+ mark(row, 'dut-sidebar-header')
47
+ mark(brand, 'dut-sidebar-brand')
48
+ mark(logo.closest('[class*="brandIdentity"]'), 'dut-sidebar-identity')
49
+ mark(name, 'dut-sidebar-wordmark')
50
+ for (const button of row.querySelectorAll('button')) {
51
+ if (!brand.contains(button) && button !== brand) mark(button, 'dut-sidebar-toggle')
52
+ }
53
+ // The host renders this native action directly after the drag row. Do
54
+ // not infer a new action from arbitrary text in another panel or dialog.
55
+ const next = row.nextElementSibling
56
+ if (next instanceof HTMLButtonElement) mark(next, 'dut-sidebar-new-session')
57
+ }
58
+ // A collapsed rail no longer has a wordmark. Remove expanded-only styling
59
+ // without changing the host's rail geometry or its logo/expand interaction.
60
+ for (const className of HEADER_CLASSES) {
61
+ for (const element of document.querySelectorAll<HTMLElement>(`.${className}`)) {
62
+ if (!desired.get(element)?.has(className)) element.classList.remove(className)
63
+ }
64
+ }
65
+ for (const [element, classes] of desired) element.classList.add(...classes)
66
+ }
67
+
68
+ /** Restore native header styling when the branding effect is disposed. */
69
+ export function clearSidebarHeader(): void {
70
+ for (const className of HEADER_CLASSES) {
71
+ for (const element of document.querySelectorAll(`.${className}`)) element.classList.remove(className)
72
+ }
73
+ }