feat(composer): fit footer controls by overflow (#4657, rebased onto master)

Rebased Paladin173's #4657 onto current master. The per-control visibility
toggles half already shipped via his own #4598, so this rebase carries only the
net-new JS fit-by-overflow engine (_fitComposerFooter: ResizeObserver +
MutationObserver measure .composer-left overflow, step full -> cf-icons ->
cf-burger). Co-authored-by: Paladin173 <Paladin173@users.noreply.github.com>
This commit is contained in:
nesquena-hermes committed 2026-06-29 01:19:02 +00:00
1 parent 1c4096aa9d
commit c6370235be
12 files changed
+454 -222

No files matched your search

+8 -1
View File
@@ -7734,7 +7734,8 @@ _SETTINGS_DEFAULTS = {
"onboarding_completed": False, "onboarding_completed": False,
"send_key": "enter", # 'enter' or 'ctrl+enter' "send_key": "enter", # 'enter' or 'ctrl+enter'
"show_token_usage": False, # show input/output token badge below assistant messages "show_token_usage": False, # show input/output token badge below assistant messages
"show_quota_chip": False, # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media) "show_quota_chip": True, # show ambient provider quota chip in composer footer by default
"show_quota_chip_opt_out": False, # post-default-flip marker for explicit user opt-out
"show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel "show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel
"hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons "hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons
"virtualize_transcript": False, # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings). "virtualize_transcript": False, # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings).
@@ -7934,6 +7935,11 @@ def load_settings() -> dict:
# Honor a stored True only when that marker is present. # Honor a stored True only when that marker is present.
if not bool(stored.get("virtualize_transcript_optin")): if not bool(stored.get("virtualize_transcript_optin")):
settings["virtualize_transcript"] = False settings["virtualize_transcript"] = False
# show_quota_chip used to be persisted as False by default. After
# flipping it visible-by-default, treat an old stored False as
# stale unless a post-flip opt-out marker is present.
if not bool(stored.get("show_quota_chip_opt_out")):
settings["show_quota_chip"] = True
except Exception: except Exception:
logger.debug("Failed to load settings from %s", SETTINGS_FILE) logger.debug("Failed to load settings from %s", SETTINGS_FILE)
@@ -7978,6 +7984,7 @@ _SETTINGS_BOOL_KEYS = {
"onboarding_completed", "onboarding_completed",
"show_token_usage", "show_token_usage",
"show_quota_chip", "show_quota_chip",
"show_quota_chip_opt_out",
"show_conversation_outline", "show_conversation_outline",
"hide_empty_state_suggestions", "hide_empty_state_suggestions",
"virtualize_transcript", "virtualize_transcript",
+3 -3
View File
@@ -2295,7 +2295,7 @@ const _COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS=[
{key:'hide_composer_yolo',label:'YOLO',labelKey:'composer_control_yolo',selectors:['#yoloPill']}, {key:'hide_composer_yolo',label:'YOLO',labelKey:'composer_control_yolo',selectors:['#yoloPill']},
{key:'hide_composer_bg_badge',label:'Background badge',labelKey:'composer_control_bg_badge',selectors:['#bgBadge']}, {key:'hide_composer_bg_badge',label:'Background badge',labelKey:'composer_control_bg_badge',selectors:['#bgBadge']},
{key:'hide_composer_mobile_config',label:'Mobile config',labelKey:'composer_control_mobile_config',selectors:['#composerMobileConfigBtn']}, {key:'hide_composer_mobile_config',label:'Mobile config',labelKey:'composer_control_mobile_config',selectors:['#composerMobileConfigBtn']},
{key:'hide_composer_quota_chip',label:'Quota chip',labelKey:'composer_control_quota_chip',selectors:['#providerQuotaChip']}, {key:'hide_composer_quota_chip',label:'Quota chip',labelKey:'composer_control_quota_chip',selectors:['#providerQuotaChip','#composerMobileQuotaAction']},
{key:'hide_composer_toolsets',label:'Toolsets',labelKey:'composer_control_toolsets',selectors:['#composerToolsetsWrap']}, {key:'hide_composer_toolsets',label:'Toolsets',labelKey:'composer_control_toolsets',selectors:['#composerToolsetsWrap']},
{key:'hide_composer_status',label:'Status',labelKey:'composer_control_status',selectors:['#composerStatus']}, {key:'hide_composer_status',label:'Status',labelKey:'composer_control_status',selectors:['#composerStatus']},
]; ];
@@ -2370,7 +2370,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
// and workspace actions (New file/folder) work before the first session (#804). // and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace; if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
window._showTokenUsage=!!s.show_token_usage; window._showTokenUsage=!!s.show_token_usage;
window._showQuotaChip=s.show_quota_chip===true; window._showQuotaChip=s.show_quota_chip!==false;
window._showConversationOutline=s.show_conversation_outline===true; window._showConversationOutline=s.show_conversation_outline===true;
document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled'; document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled';
if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
@@ -2504,7 +2504,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
}catch(e){ }catch(e){
window._sendKey='enter'; window._sendKey='enter';
window._showTokenUsage=false; window._showTokenUsage=false;
window._showQuotaChip=false; window._showQuotaChip=true;
window._showConversationOutline=false; window._showConversationOutline=false;
document.documentElement.dataset.conversationOutline='disabled'; document.documentElement.dataset.conversationOutline='disabled';
if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
+28 -14
View File
@@ -811,7 +811,7 @@ const LOCALES = {
settings_autosave_retry: 'Retry', settings_autosave_retry: 'Retry',
settings_label_language: 'Language', settings_label_language: 'Language',
settings_label_quota_chip: 'Show provider quota chip in composer', settings_label_quota_chip: 'Show provider quota chip in composer',
settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Default off. Only visible on wide displays (≥1400px) when enabled, to keep the composer uncluttered on laptop and standard desktop widths.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Show conversation outline', settings_label_conversation_outline: 'Show conversation outline',
settings_desc_conversation_outline: 'Show a desktop-only jump list for user questions in the current conversation. Off by default.', settings_desc_conversation_outline: 'Show a desktop-only jump list for user questions in the current conversation. Off by default.',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -1600,6 +1600,7 @@ const LOCALES = {
composer_disabled_empty: 'Type a message to send', composer_disabled_empty: 'Type a message to send',
composer_mobile_workspace: 'Workspace', composer_mobile_workspace: 'Workspace',
composer_mobile_model: 'Model', composer_mobile_model: 'Model',
composer_mobile_quota: 'Quota',
composer_mobile_reasoning: 'Reasoning', composer_mobile_reasoning: 'Reasoning',
composer_mobile_context: 'Context', composer_mobile_context: 'Context',
media_audio_label: 'Audio', media_audio_label: 'Audio',
@@ -2449,7 +2450,7 @@ const LOCALES = {
settings_autosave_retry: 'Riprova', settings_autosave_retry: 'Riprova',
settings_label_language: 'Lingua', settings_label_language: 'Lingua',
settings_label_quota_chip: 'Mostra il chip della quota del provider nel compositore', settings_label_quota_chip: 'Mostra il chip della quota del provider nel compositore',
settings_desc_quota_chip: "Mostra un indicatore di quota residua (es. saldo crediti OpenRouter) nel piè di pagina del compositore. Predefinito disattivato. Visibile solo su schermi larghi (≥1400px) quando attivato, per mantenere il compositore non affollato su laptop e desktop standard.", settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Mostra struttura conversazione', settings_label_conversation_outline: 'Mostra struttura conversazione',
settings_desc_conversation_outline: 'Mostra un elenco di salto solo desktop per le domande utente nella conversazione corrente. Disattivato per impostazione predefinita.', settings_desc_conversation_outline: 'Mostra un elenco di salto solo desktop per le domande utente nella conversazione corrente. Disattivato per impostazione predefinita.',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -3226,6 +3227,7 @@ const LOCALES = {
composer_disabled_empty: 'Scrivi un messaggio da inviare', composer_disabled_empty: 'Scrivi un messaggio da inviare',
composer_mobile_workspace: 'Workspace', composer_mobile_workspace: 'Workspace',
composer_mobile_model: 'Modello', composer_mobile_model: 'Modello',
composer_mobile_quota: 'Quota',
composer_mobile_reasoning: 'Ragionamento', composer_mobile_reasoning: 'Ragionamento',
composer_mobile_context: 'Contesto', composer_mobile_context: 'Contesto',
media_audio_label: 'Audio', media_audio_label: 'Audio',
@@ -4078,7 +4080,7 @@ const LOCALES = {
settings_autosave_retry: '再試行', settings_autosave_retry: '再試行',
settings_label_language: '言語', settings_label_language: '言語',
settings_label_quota_chip: 'コンポーザーにプロバイダーのクォータチップを表示', settings_label_quota_chip: 'コンポーザーにプロバイダーのクォータチップを表示',
settings_desc_quota_chip: 'コンポーザーのフッターに残りクォータインジケーター(例: OpenRouter のクレジット残高)を表示します。デフォルトはオフ。有効にした場合、ラップトップや標準デスクトップの幅でコンポーザーが混雑しないよう、ワイドディスプレイ(≥1400px)でのみ表示されます。', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: '会話アウトラインを表示', settings_label_conversation_outline: '会話アウトラインを表示',
settings_desc_conversation_outline: '現在の会話内のユーザー質問へ移動するデスクトップ専用リストを表示します。デフォルトはオフです。', settings_desc_conversation_outline: '現在の会話内のユーザー質問へ移動するデスクトップ専用リストを表示します。デフォルトはオフです。',
settings_label_hide_suggestions: '新規チャットの候補を非表示', settings_label_hide_suggestions: '新規チャットの候補を非表示',
@@ -4860,6 +4862,7 @@ const LOCALES = {
composer_disabled_empty: '送信するメッセージを入力してください', composer_disabled_empty: '送信するメッセージを入力してください',
composer_mobile_workspace: 'ワークスペース', composer_mobile_workspace: 'ワークスペース',
composer_mobile_model: 'モデル', composer_mobile_model: 'モデル',
composer_mobile_quota: 'クォータ',
composer_mobile_reasoning: '思考', composer_mobile_reasoning: '思考',
composer_mobile_context: 'コンテキスト', composer_mobile_context: 'コンテキスト',
media_audio_label: '音声', media_audio_label: '音声',
@@ -5421,7 +5424,7 @@ const LOCALES = {
settings_label_theme: 'Тема', settings_label_theme: 'Тема',
settings_label_language: 'Язык', settings_label_language: 'Язык',
settings_label_quota_chip: 'Показывать чип квоты провайдера в композиторе', settings_label_quota_chip: 'Показывать чип квоты провайдера в композиторе',
settings_desc_quota_chip: 'Отображает фоновый индикатор остатка квоты (например, баланс кредитов OpenRouter) в подвале композитора. По умолчанию отключено. Виден только на широких экранах (≥1400px) при включении, чтобы не загромождать композитор на ноутбуках и стандартных мониторах.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Показывать структуру беседы', settings_label_conversation_outline: 'Показывать структуру беседы',
settings_desc_conversation_outline: 'Показывает на рабочем столе список переходов к вопросам пользователя в текущей беседе. По умолчанию отключено.', settings_desc_conversation_outline: 'Показывает на рабочем столе список переходов к вопросам пользователя в текущей беседе. По умолчанию отключено.',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -6357,6 +6360,7 @@ const LOCALES = {
composer_disabled_empty: 'Введите сообщение для отправки', composer_disabled_empty: 'Введите сообщение для отправки',
composer_mobile_workspace: 'Рабочее пространство', composer_mobile_workspace: 'Рабочее пространство',
composer_mobile_model: 'Модель', composer_mobile_model: 'Модель',
composer_mobile_quota: 'Квота',
composer_mobile_reasoning: 'Рассуждение', composer_mobile_reasoning: 'Рассуждение',
composer_mobile_context: 'Контекст', composer_mobile_context: 'Контекст',
media_audio_label: 'Аудио', media_audio_label: 'Аудио',
@@ -6977,7 +6981,7 @@ const LOCALES = {
settings_autosave_retry: 'Reintentar', settings_autosave_retry: 'Reintentar',
settings_label_language: 'Idioma', settings_label_language: 'Idioma',
settings_label_quota_chip: 'Mostrar el chip de cuota del proveedor en el compositor', settings_label_quota_chip: 'Mostrar el chip de cuota del proveedor en el compositor',
settings_desc_quota_chip: 'Muestra un indicador ambiental de cuota restante (por ejemplo, saldo de crédito de OpenRouter) en el pie del compositor. Predeterminado: desactivado. Solo visible en pantallas anchas (≥1400px) cuando se activa, para mantener el compositor despejado en portátiles y monitores estándar.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Mostrar esquema de conversación', settings_label_conversation_outline: 'Mostrar esquema de conversación',
settings_desc_conversation_outline: 'Muestra una lista de salto solo en escritorio para las preguntas del usuario en la conversación actual. Desactivado de forma predeterminada.', settings_desc_conversation_outline: 'Muestra una lista de salto solo en escritorio para las preguntas del usuario en la conversación actual. Desactivado de forma predeterminada.',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -7911,6 +7915,7 @@ const LOCALES = {
composer_disabled_empty: 'Escribe un mensaje para enviar', composer_disabled_empty: 'Escribe un mensaje para enviar',
composer_mobile_workspace: 'Espacio de trabajo', composer_mobile_workspace: 'Espacio de trabajo',
composer_mobile_model: 'Modelo', composer_mobile_model: 'Modelo',
composer_mobile_quota: 'Cuota',
composer_mobile_reasoning: 'Razonamiento', composer_mobile_reasoning: 'Razonamiento',
composer_mobile_context: 'Contexto', composer_mobile_context: 'Contexto',
media_audio_label: 'Audio', media_audio_label: 'Audio',
@@ -8521,7 +8526,7 @@ const LOCALES = {
settings_autosave_retry: 'Wiederholen', settings_autosave_retry: 'Wiederholen',
settings_label_language: 'Sprache', settings_label_language: 'Sprache',
settings_label_quota_chip: 'Anbieter-Kontingent-Chip im Editor anzeigen', settings_label_quota_chip: 'Anbieter-Kontingent-Chip im Editor anzeigen',
settings_desc_quota_chip: 'Zeigt einen Hintergrund-Indikator des verbleibenden Kontingents (z. B. OpenRouter-Guthaben) in der Editor-Fußzeile an. Standardmäßig deaktiviert. Bei Aktivierung nur auf breiten Bildschirmen (≥1400px) sichtbar, damit der Editor auf Laptops und Standard-Desktops übersichtlich bleibt.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Konversationsgliederung anzeigen', settings_label_conversation_outline: 'Konversationsgliederung anzeigen',
settings_desc_conversation_outline: 'Zeigt eine Desktop-Sprungliste für Benutzerfragen in der aktuellen Konversation. Standardmäßig deaktiviert.', settings_desc_conversation_outline: 'Zeigt eine Desktop-Sprungliste für Benutzerfragen in der aktuellen Konversation. Standardmäßig deaktiviert.',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -9149,6 +9154,7 @@ const LOCALES = {
composer_disabled_empty: 'Nachricht eingeben zum Senden', composer_disabled_empty: 'Nachricht eingeben zum Senden',
composer_mobile_workspace: 'Arbeitsbereich', composer_mobile_workspace: 'Arbeitsbereich',
composer_mobile_model: 'Modell', composer_mobile_model: 'Modell',
composer_mobile_quota: 'Kontingent',
composer_mobile_reasoning: 'Reasoning', composer_mobile_reasoning: 'Reasoning',
composer_mobile_context: 'Kontext', composer_mobile_context: 'Kontext',
model_custom_label: 'Benutzerdefinierte Modell-ID', model_custom_label: 'Benutzerdefinierte Modell-ID',
@@ -10130,7 +10136,7 @@ const LOCALES = {
settings_autosave_retry: '重试', settings_autosave_retry: '重试',
settings_label_language: '语言', settings_label_language: '语言',
settings_label_quota_chip: '在编辑器中显示供应商配额标签', settings_label_quota_chip: '在编辑器中显示供应商配额标签',
settings_desc_quota_chip: '在编辑器底部显示剩余配额指示器(如 OpenRouter 信用余额)。默认关闭。启用时仅在宽屏(≥1400px)显示,以保持笔记本和标准桌面屏幕上编辑器的整洁。', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: '显示会话大纲', settings_label_conversation_outline: '显示会话大纲',
settings_desc_conversation_outline: '显示仅限桌面的跳转列表,用于跳转到当前会话中的用户问题。默认关闭。', settings_desc_conversation_outline: '显示仅限桌面的跳转列表,用于跳转到当前会话中的用户问题。默认关闭。',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -11041,6 +11047,7 @@ const LOCALES = {
composer_disabled_empty: '请输入消息后发送', composer_disabled_empty: '请输入消息后发送',
composer_mobile_workspace: '工作区', composer_mobile_workspace: '工作区',
composer_mobile_model: '模型', composer_mobile_model: '模型',
composer_mobile_quota: '配额',
composer_mobile_reasoning: '推理', composer_mobile_reasoning: '推理',
composer_mobile_context: '上下文', composer_mobile_context: '上下文',
@@ -11974,7 +11981,7 @@ const LOCALES = {
settings_autosave_retry: '重試', settings_autosave_retry: '重試',
settings_label_language: '語言', settings_label_language: '語言',
settings_label_quota_chip: '在編輯器中顯示供應商配額標籤', settings_label_quota_chip: '在編輯器中顯示供應商配額標籤',
settings_desc_quota_chip: '在編輯器底部顯示剩餘配額指示器(如 OpenRouter 點數餘額)。預設關閉。啟用時僅在寬螢幕(≥1400px)顯示,以保持筆記型電腦和標準桌面螢幕上編輯器的整潔。', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: '顯示對話大綱', settings_label_conversation_outline: '顯示對話大綱',
settings_desc_conversation_outline: '顯示僅限桌面的跳轉清單,用於跳轉到目前對話中的使用者問題。預設關閉。', settings_desc_conversation_outline: '顯示僅限桌面的跳轉清單,用於跳轉到目前對話中的使用者問題。預設關閉。',
settings_label_hide_suggestions: '隱藏新聊天建議', settings_label_hide_suggestions: '隱藏新聊天建議',
@@ -12759,6 +12766,7 @@ const LOCALES = {
composer_disabled_empty: '請輸入訊息後傳送', composer_disabled_empty: '請輸入訊息後傳送',
composer_mobile_workspace: '工作區', composer_mobile_workspace: '工作區',
composer_mobile_model: '模型', composer_mobile_model: '模型',
composer_mobile_quota: '配額',
composer_mobile_reasoning: '推理', composer_mobile_reasoning: '推理',
composer_mobile_context: '脈絡', composer_mobile_context: '脈絡',
media_audio_label: '音訊', media_audio_label: '音訊',
@@ -12922,6 +12930,7 @@ const LOCALES = {
model_not_found_label: 'Modelo não encontrado', model_not_found_label: 'Modelo não encontrado',
composer_mobile_workspace: 'Workspace', composer_mobile_workspace: 'Workspace',
composer_mobile_model: 'Modelo', composer_mobile_model: 'Modelo',
composer_mobile_quota: 'Cota',
composer_mobile_reasoning: 'Raciocínio', composer_mobile_reasoning: 'Raciocínio',
composer_mobile_context: 'Contexto', composer_mobile_context: 'Contexto',
model_custom_label: 'ID de modelo customizado', model_custom_label: 'ID de modelo customizado',
@@ -13497,7 +13506,7 @@ const LOCALES = {
settings_autosave_retry: 'Tentar novamente', settings_autosave_retry: 'Tentar novamente',
settings_label_language: 'Idioma', settings_label_language: 'Idioma',
settings_label_quota_chip: 'Mostrar o chip de cota do provedor no compositor', settings_label_quota_chip: 'Mostrar o chip de cota do provedor no compositor',
settings_desc_quota_chip: 'Exibe um indicador ambiente de cota restante (por exemplo, saldo de crédito do OpenRouter) no rodapé do compositor. Desativado por padrão. Visível apenas em telas largas (≥1400px) quando ativado, para manter o compositor livre em laptops e monitores padrão.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Mostrar estrutura da conversa', settings_label_conversation_outline: 'Mostrar estrutura da conversa',
settings_desc_conversation_outline: 'Mostra uma lista de atalhos somente no desktop para as perguntas do usuário na conversa atual. Desativado por padrão.', settings_desc_conversation_outline: 'Mostra uma lista de atalhos somente no desktop para as perguntas do usuário na conversa atual. Desativado por padrão.',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -15026,7 +15035,7 @@ const LOCALES = {
settings_autosave_retry: '다시 시도', settings_autosave_retry: '다시 시도',
settings_label_language: '언어', settings_label_language: '언어',
settings_label_quota_chip: '작성기에 공급자 할당량 칩 표시', settings_label_quota_chip: '작성기에 공급자 할당량 칩 표시',
settings_desc_quota_chip: '작성기 푸터에 남은 할당량 표시기(예: OpenRouter 크레딧 잔액)를 표시합니다. 기본값은 끔. 활성화 시 노트북과 표준 데스크톱에서 작성기가 복잡해지지 않도록 와이드 디스플레이(≥1400px)에서만 표시됩니다.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: '대화 개요 표시', settings_label_conversation_outline: '대화 개요 표시',
settings_desc_conversation_outline: '현재 대화의 사용자 질문으로 이동하는 데스크톱 전용 목록을 표시합니다. 기본값은 꺼짐입니다.', settings_desc_conversation_outline: '현재 대화의 사용자 질문으로 이동하는 데스크톱 전용 목록을 표시합니다. 기본값은 꺼짐입니다.',
settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_label_hide_suggestions: 'Hide new-chat suggestions',
@@ -15742,6 +15751,7 @@ const LOCALES = {
composer_disabled_empty: '메시지를 입력하세요', composer_disabled_empty: '메시지를 입력하세요',
composer_mobile_workspace: '워크스페이스', composer_mobile_workspace: '워크스페이스',
composer_mobile_model: '모델', composer_mobile_model: '모델',
composer_mobile_quota: '할당량',
composer_mobile_reasoning: '추론', composer_mobile_reasoning: '추론',
composer_mobile_context: '컨텍스트', composer_mobile_context: '컨텍스트',
@@ -16667,7 +16677,7 @@ const LOCALES = {
settings_autosave_retry: 'Réessayer', settings_autosave_retry: 'Réessayer',
settings_label_language: 'Langue', settings_label_language: 'Langue',
settings_label_quota_chip: 'Afficher le jeton de quota du fournisseur dans le compositeur', settings_label_quota_chip: 'Afficher le jeton de quota du fournisseur dans le compositeur',
settings_desc_quota_chip: 'Affiche un indicateur de quota restant (ex. solde de crédit OpenRouter) dans le pied de page du compositeur. Désactivé par défaut. Visible uniquement sur les grands écrans (≥1400px) lorsqu\'il est activé, pour garder le compositeur épuré sur les ordinateurs portables et les écrans de bureau standards.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Afficher le plan de la conversation', settings_label_conversation_outline: 'Afficher le plan de la conversation',
settings_desc_conversation_outline: 'Afficher une liste de saut réservée au bureau pour les questions utilisateur dans la conversation actuelle. Désactivé par défaut.', settings_desc_conversation_outline: 'Afficher une liste de saut réservée au bureau pour les questions utilisateur dans la conversation actuelle. Désactivé par défaut.',
settings_label_hide_suggestions: 'Masquer les suggestions de nouvelle conversation', settings_label_hide_suggestions: 'Masquer les suggestions de nouvelle conversation',
@@ -17435,6 +17445,7 @@ const LOCALES = {
composer_disabled_empty: 'Saisissez un message à envoyer', composer_disabled_empty: 'Saisissez un message à envoyer',
composer_mobile_workspace: 'Espace de travail', composer_mobile_workspace: 'Espace de travail',
composer_mobile_model: 'Modèle', composer_mobile_model: 'Modèle',
composer_mobile_quota: 'Quota',
composer_mobile_reasoning: 'Raisonnement', composer_mobile_reasoning: 'Raisonnement',
composer_mobile_context: 'Contexte', composer_mobile_context: 'Contexte',
media_audio_label: 'Audio', media_audio_label: 'Audio',
@@ -18264,7 +18275,7 @@ const LOCALES = {
settings_autosave_retry: 'Yeniden dene', settings_autosave_retry: 'Yeniden dene',
settings_label_language: 'Dil', settings_label_language: 'Dil',
settings_label_quota_chip: 'Bestecide sağlayıcı kota çipini göster', settings_label_quota_chip: 'Bestecide sağlayıcı kota çipini göster',
settings_desc_quota_chip: 'Besteci altbilgisinde bir ortam kalan kota göstergesini (örn. OpenRouter kredi bakiyesi) görüntüler. Varsayılan kapalı. Besteciyi dizüstü bilgisayar ve standart masaüstü genişliklerinde düzenli tutmak için yalnızca etkinleştirildiğinde geniş ekranlarda (≥1400 piksel) görünür.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Konuşma ana hattını göster', settings_label_conversation_outline: 'Konuşma ana hattını göster',
settings_desc_conversation_outline: 'Geçerli konuşmadaki kullanıcı soruları için yalnızca masaüstünde görünen bir atlama listesi gösterir. Varsayılan kapalıdır.', settings_desc_conversation_outline: 'Geçerli konuşmadaki kullanıcı soruları için yalnızca masaüstünde görünen bir atlama listesi gösterir. Varsayılan kapalıdır.',
settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle', settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle',
@@ -18978,6 +18989,7 @@ const LOCALES = {
composer_disabled_empty: 'Göndermek için bir mesaj yazın', composer_disabled_empty: 'Göndermek için bir mesaj yazın',
composer_mobile_workspace: 'Çalışma alanı', composer_mobile_workspace: 'Çalışma alanı',
composer_mobile_model: 'Modeli', composer_mobile_model: 'Modeli',
composer_mobile_quota: 'Kota',
composer_mobile_reasoning: 'muhakeme', composer_mobile_reasoning: 'muhakeme',
composer_mobile_context: 'Bağlam', composer_mobile_context: 'Bağlam',
@@ -19907,7 +19919,7 @@ const LOCALES = {
settings_autosave_retry: 'Ponów próbę', settings_autosave_retry: 'Ponów próbę',
settings_label_language: 'Język', settings_label_language: 'Język',
settings_label_quota_chip: 'Pokaż wskaźnik limitu dostawcy w polu wprowadzania', settings_label_quota_chip: 'Pokaż wskaźnik limitu dostawcy w polu wprowadzania',
settings_desc_quota_chip: 'Wyświetla subtelny wskaźnik pozostałego limitu (np. saldo kredytów OpenRouter) w stopce pola wprowadzania. Domyślnie wyłączone. Widoczne tylko na szerokich ekranach (≥1400px), aby utrzymać pole wprowadzania czytelnym na laptopach i standardowych monitorach.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Pokaż zarys konwersacji', settings_label_conversation_outline: 'Pokaż zarys konwersacji',
settings_desc_conversation_outline: 'Pokazuje desktopową listę skrótów do pytań użytkownika w bieżącej konwersacji. Domyślnie wyłączone.', settings_desc_conversation_outline: 'Pokazuje desktopową listę skrótów do pytań użytkownika w bieżącej konwersacji. Domyślnie wyłączone.',
settings_label_hide_suggestions: 'Ukryj sugestie nowej konwersacji', settings_label_hide_suggestions: 'Ukryj sugestie nowej konwersacji',
@@ -20686,6 +20698,7 @@ const LOCALES = {
composer_disabled_empty: 'Wpisz wiadomość, aby wysłać', composer_disabled_empty: 'Wpisz wiadomość, aby wysłać',
composer_mobile_workspace: 'Obszar roboczy', composer_mobile_workspace: 'Obszar roboczy',
composer_mobile_model: 'Model', composer_mobile_model: 'Model',
composer_mobile_quota: 'Limit',
composer_mobile_reasoning: 'Myślenie', composer_mobile_reasoning: 'Myślenie',
composer_mobile_context: 'Kontekst', composer_mobile_context: 'Kontekst',
media_audio_label: 'Dźwięk', media_audio_label: 'Dźwięk',
@@ -21380,7 +21393,7 @@ const LOCALES = {
settings_autosave_retry: 'Thử lại', settings_autosave_retry: 'Thử lại',
settings_label_language: 'Ngôn ngữ', settings_label_language: 'Ngôn ngữ',
settings_label_quota_chip: 'Hiện hạn mức nhà cung cấp trong khung soạn thảo', settings_label_quota_chip: 'Hiện hạn mức nhà cung cấp trong khung soạn thảo',
settings_desc_quota_chip: 'Hiển thị chỉ báo quota còn lại (ví dụ: số dư credit OpenRouter) ở footer của composer. Mặc định tắt. Chỉ hiển thị trên màn hình rộng (≥1400px) khi bật để giữ composer gọn gàng trên laptop và desktop thông thường.', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.',
settings_label_conversation_outline: 'Hiện mục lục cuộc trò chuyện', settings_label_conversation_outline: 'Hiện mục lục cuộc trò chuyện',
settings_desc_conversation_outline: 'Hiển thị danh sách nhảy nhanh cho các câu hỏi của người dùng trong cuộc trò chuyện hiện tại trên desktop. Mặc định tắt.', settings_desc_conversation_outline: 'Hiển thị danh sách nhảy nhanh cho các câu hỏi của người dùng trong cuộc trò chuyện hiện tại trên desktop. Mặc định tắt.',
settings_label_hide_suggestions: 'Ẩn gợi ý chat mới', settings_label_hide_suggestions: 'Ẩn gợi ý chat mới',
@@ -22170,6 +22183,7 @@ const LOCALES = {
composer_compression_will_queue: 'Nhập tin nhắn — hệ thống sẽ xếp hàng và gửi sau khi nén xong', composer_compression_will_queue: 'Nhập tin nhắn — hệ thống sẽ xếp hàng và gửi sau khi nén xong',
composer_mobile_workspace: 'Workspace', composer_mobile_workspace: 'Workspace',
composer_mobile_model: 'Model', composer_mobile_model: 'Model',
composer_mobile_quota: 'Hạn mức',
composer_mobile_reasoning: 'Lập luận', composer_mobile_reasoning: 'Lập luận',
composer_mobile_context: 'Ngữ cảnh', composer_mobile_context: 'Ngữ cảnh',
context_block_name_default: 'Ngữ cảnh', context_block_name_default: 'Ngữ cảnh',
+7 -3
View File
@@ -624,7 +624,7 @@
</button> </button>
</div> </div>
</div> </div>
<button class="icon-btn composer-mobile-config-btn" id="composerMobileConfigBtn" type="button" onclick="toggleMobileComposerConfig()" title="Workspace, model, reasoning, and context settings" aria-label="Workspace, model, reasoning, and context settings" aria-haspopup="true" aria-expanded="false" aria-controls="composerMobileConfigPanel"> <button class="icon-btn composer-mobile-config-btn" id="composerMobileConfigBtn" type="button" onclick="toggleMobileComposerConfig()" title="Workspace, model, quota, reasoning, and context settings" aria-label="Workspace, model, quota, reasoning, and context settings" aria-haspopup="true" aria-expanded="false" aria-controls="composerMobileConfigPanel">
<svg id="composerMobileCtxRing" viewBox="0 0 36 36" width="24" height="24" style="display:block;overflow:visible;transform:rotate(-90deg)" aria-hidden="true"><circle cx="18" cy="18" r="14" fill="none" stroke="currentColor" stroke-width="3.5" opacity="0.2"/><circle id="ctx-arc" cx="18" cy="18" r="14" fill="none" stroke="#22c55e" stroke-width="3.5" stroke-dasharray="87.96 87.96" stroke-dashoffset="87.96" stroke-linecap="round"/><text id="ctx-num" x="18" y="18" text-anchor="middle" dominant-baseline="middle" style="transform:rotate(90deg);transform-origin:18px 18px;font-size:9px;font-weight:700;fill:currentColor;stroke:none">0</text></svg> <svg id="composerMobileCtxRing" viewBox="0 0 36 36" width="24" height="24" style="display:block;overflow:visible;transform:rotate(-90deg)" aria-hidden="true"><circle cx="18" cy="18" r="14" fill="none" stroke="currentColor" stroke-width="3.5" opacity="0.2"/><circle id="ctx-arc" cx="18" cy="18" r="14" fill="none" stroke="#22c55e" stroke-width="3.5" stroke-dasharray="87.96 87.96" stroke-dashoffset="87.96" stroke-linecap="round"/><text id="ctx-num" x="18" y="18" text-anchor="middle" dominant-baseline="middle" style="transform:rotate(90deg);transform-origin:18px 18px;font-size:9px;font-weight:700;fill:currentColor;stroke:none">0</text></svg>
</button> </button>
<div class="composer-model-wrap"> <div class="composer-model-wrap">
@@ -703,7 +703,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button> </button>
</div> </div>
<div class="composer-mobile-config-panel" id="composerMobileConfigPanel" aria-label="Workspace, model, reasoning, and context settings"> <div class="composer-mobile-config-panel" id="composerMobileConfigPanel" aria-label="Workspace, model, quota, reasoning, and context settings">
<button class="composer-mobile-config-action" id="composerMobileWorkspaceAction" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace"> <button class="composer-mobile-config-action" id="composerMobileWorkspaceAction" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace">
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span> <span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_workspace">Workspace</span><span class="composer-mobile-config-value" id="composerMobileWorkspaceLabel"></span></span> <span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_workspace">Workspace</span><span class="composer-mobile-config-value" id="composerMobileWorkspaceLabel"></span></span>
@@ -712,6 +712,10 @@
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span> <span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
<span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_model">Model</span><span class="composer-mobile-config-value" id="composerMobileModelLabel"></span></span> <span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_model">Model</span><span class="composer-mobile-config-value" id="composerMobileModelLabel"></span></span>
</button> </button>
<button class="composer-mobile-config-action" id="composerMobileQuotaAction" type="button" onclick="switchPanel('settings');switchSettingsSection('providers')" title="Provider quota" style="display:none">
<span class="composer-model-icon" aria-hidden="true"><span class="provider-quota-chip-dot" style="box-shadow:none"></span></span>
<span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_quota">Quota</span><span class="composer-mobile-config-value" id="composerMobileQuotaLabel"></span></span>
</button>
<button class="composer-mobile-config-action" id="composerMobileReasoningAction" type="button" onclick="toggleReasoningDropdown()" title="Reasoning effort level" style="display:none"> <button class="composer-mobile-config-action" id="composerMobileReasoningAction" type="button" onclick="toggleReasoningDropdown()" title="Reasoning effort level" style="display:none">
<span class="composer-reasoning-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/></svg></span> <span class="composer-reasoning-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/></svg></span>
<span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_reasoning">Reasoning</span><span class="composer-mobile-config-value" id="composerMobileReasoningLabel"></span></span> <span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_reasoning">Reasoning</span><span class="composer-mobile-config-value" id="composerMobileReasoningLabel"></span></span>
@@ -1233,7 +1237,7 @@
<input type="checkbox" id="settingsShowQuotaChip" style="width:15px;height:15px;accent-color:var(--accent)"> <input type="checkbox" id="settingsShowQuotaChip" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_quota_chip">Show provider quota chip in composer</span> <span data-i18n="settings_label_quota_chip">Show provider quota chip in composer</span>
</label> </label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_quota_chip">Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Default off. Only visible on wide displays (≥1400px) when enabled, to keep the composer uncluttered on laptop and standard desktop widths.</div> <div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_quota_chip">Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Shown by default when provider quota data is available.</div>
</div> </div>
<div class="settings-field"> <div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
+7 -4
View File
@@ -7377,7 +7377,10 @@ function _preferencesPayloadFromUi(){
const showUsageCb=$('settingsShowTokenUsage'); const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb) payload.show_token_usage=showUsageCb.checked; if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
const showQuotaChipCb=$('settingsShowQuotaChip'); const showQuotaChipCb=$('settingsShowQuotaChip');
if(showQuotaChipCb) payload.show_quota_chip=showQuotaChipCb.checked; if(showQuotaChipCb){
payload.show_quota_chip=showQuotaChipCb.checked;
payload.show_quota_chip_opt_out=!showQuotaChipCb.checked;
}
const showConversationOutlineCb=$('settingsShowConversationOutline'); const showConversationOutlineCb=$('settingsShowConversationOutline');
if(showConversationOutlineCb) payload.show_conversation_outline=showConversationOutlineCb.checked; if(showConversationOutlineCb) payload.show_conversation_outline=showConversationOutlineCb.checked;
const hideSuggestionsCb=$('settingsHideSuggestions'); const hideSuggestionsCb=$('settingsHideSuggestions');
@@ -7812,11 +7815,10 @@ async function loadSettingsPanel(){
maxTokensField.dataset.initialValue=maxTokensField.value; maxTokensField.dataset.initialValue=maxTokensField.value;
maxTokensField.addEventListener('input',_markSettingsDirty,{once:false}); maxTokensField.addEventListener('input',_markSettingsDirty,{once:false});
} }
// Ambient provider quota chip toggle — default off; only shows at ≥1400px viewport // Ambient provider quota chip toggle — default on when provider quota data is available.
// when enabled (see style.css @media (max-width:1399.98px) rule).
const showQuotaChipCb=$('settingsShowQuotaChip'); const showQuotaChipCb=$('settingsShowQuotaChip');
if(showQuotaChipCb){ if(showQuotaChipCb){
showQuotaChipCb.checked=settings.show_quota_chip===true; showQuotaChipCb.checked=settings.show_quota_chip!==false;
window._showQuotaChip=showQuotaChipCb.checked; window._showQuotaChip=showQuotaChipCb.checked;
showQuotaChipCb.addEventListener('change',()=>{ showQuotaChipCb.addEventListener('change',()=>{
window._showQuotaChip=showQuotaChipCb.checked; window._showQuotaChip=showQuotaChipCb.checked;
@@ -10557,6 +10559,7 @@ async function saveSettings(andClose){
} }
} }
body.show_quota_chip=showQuotaChip===true; body.show_quota_chip=showQuotaChip===true;
body.show_quota_chip_opt_out=showQuotaChip!==true;
body.show_conversation_outline=showConversationOutline===true; body.show_conversation_outline=showConversationOutline===true;
body.show_tps=showTps; body.show_tps=showTps;
body.fade_text_effect=fadeTextEffect; body.fade_text_effect=fadeTextEffect;
+38 -77
View File
@@ -2397,12 +2397,13 @@
.composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);} .composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);}
.composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-reasoning-chip{display:inline-flex;align-items:center;gap:5px;max-width:none;padding:8px 10px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-reasoning-chip.inactive{opacity:.78;} .composer-reasoning-chip.inactive{opacity:.78;}
.composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);} .composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-reasoning-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} .composer-reasoning-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
.composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-reasoning-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .composer-reasoning-chevron{display:none;}
.composer-reasoning-label{display:inline-flex;align-items:center;justify-content:center;min-width:1.6ch;font-size:11px;font-weight:700;letter-spacing:0;text-transform:none;}
.composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:140px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;padding:4px;overflow:hidden;} .composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:140px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;padding:4px;overflow:hidden;}
.composer-reasoning-dropdown.open{display:block;} .composer-reasoning-dropdown.open{display:block;}
.reasoning-option{padding:8px 14px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap;transition:background-color .12s;} .reasoning-option{padding:8px 14px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap;transition:background-color .12s;}
@@ -2441,29 +2442,13 @@
.provider-quota-chip:hover{background:rgba(34,197,94,.16);border-color:rgba(34,197,94,.35);} .provider-quota-chip:hover{background:rgba(34,197,94,.16);border-color:rgba(34,197,94,.35);}
.provider-quota-chip-dot{width:6px;height:6px;border-radius:999px;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.12);flex:0 0 auto;} .provider-quota-chip-dot{width:6px;height:6px;border-radius:999px;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.12);flex:0 0 auto;}
.provider-quota-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .provider-quota-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* Hide the ambient quota chip below 1400px — the composer footer already
has profile + workspace + model picker + reasoning + media + send and
was getting cramped (model picker truncating from "Claude Sonnet 4 7"
to "Claude Sonnet 4", workspace dropping more text) once the chip
squeezed in. Mobile users get provider info via the dedicated
mobile-config drawer; laptop desktop users can find quota in
Settings → Providers (the chip's onclick target). Show only on wide
desktop where there's genuine room. Aron's standing position on
composer real estate (PR #1721, May 13 2026) reinforces this.
Using 1399.98px rather than 1400px to avoid the one-pixel boundary
overlap with the existing @media (min-width:1400px) `.messages-inner`
rule at line 893 — at exactly 1400px both would fire, so the chip
was hidden AT the wide-desktop boundary where it should first appear. */
@media (max-width:1399.98px){
.provider-quota-chip{display:none!important;}
}
/* Increased specificity (.icon-btn.composer-mobile-config-btn) to win the cascade /* Increased specificity (.icon-btn.composer-mobile-config-btn) to win the cascade
against the later-in-source `.icon-btn { display:flex }` rule that this element against the later-in-source `.icon-btn { display:flex }` rule that this element
also matches. Without this, the button shows at desktop width — see #1381 review. */ also matches. Without this, the button shows at desktop width — see #1381 review. */
.icon-btn.composer-mobile-config-btn{display:none;} .icon-btn.composer-mobile-config-btn{display:none;}
.composer-mobile-config-btn.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--text);opacity:1;} .composer-mobile-config-btn.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--text);opacity:1;}
.composer-mobile-config-panel{display:none;position:absolute;left:8px;right:8px;bottom:calc(100% + 6px);z-index:180;padding:8px;gap:8px;align-items:stretch;justify-content:flex-start;flex-wrap:wrap;background:var(--surface);border:1px solid var(--border2);border-radius:12px;box-shadow:0 -6px 28px rgba(0,0,0,.35);overflow:visible;} .composer-mobile-config-panel{display:none;position:absolute;left:8px;right:8px;bottom:calc(100% + 6px);z-index:180;padding:8px;gap:8px;align-items:stretch;justify-content:flex-start;flex-wrap:wrap;background:var(--surface);border:1px solid var(--border2);border-radius:12px;box-shadow:0 -6px 28px rgba(0,0,0,.35);overflow:visible;}
.composer-mobile-config-panel.open{display:flex;}
.composer-mobile-config-action{box-sizing:border-box;display:inline-flex;align-items:center;gap:10px;min-width:0;min-height:44px;padding:8px 10px;border-radius:10px;border:1px solid transparent;background:transparent;color:var(--muted);font:inherit;text-align:left;cursor:pointer;overflow:hidden;} .composer-mobile-config-action{box-sizing:border-box;display:inline-flex;align-items:center;gap:10px;min-width:0;min-height:44px;padding:8px 10px;border-radius:10px;border:1px solid transparent;background:transparent;color:var(--muted);font:inherit;text-align:left;cursor:pointer;overflow:hidden;}
.composer-mobile-config-action.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--text);} .composer-mobile-config-action.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--text);}
.composer-mobile-config-copy{display:flex;flex-direction:column;gap:1px;min-width:0;line-height:1.2;} .composer-mobile-config-copy{display:flex;flex-direction:column;gap:1px;min-width:0;line-height:1.2;}
@@ -2769,67 +2754,43 @@
.workspace-panel-edge-toggle{display:none!important;} .workspace-panel-edge-toggle{display:none!important;}
.rightpanel{display:none} .rightpanel{display:none}
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;} .workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
.app-titlebar{justify-content:space-between;}
.app-titlebar-hamburger,.app-titlebar-spacer{display:flex;}
} }
@container composer-footer (max-width: 700px){ /* Fit-based composer footer collapse. ui.js measures actual overflow on
/* Stage 1: remove long workspace/model text first to avoid clipping. .composer-left and toggles these stage classes on .composer-footer. */
The left sidebar + panel layout can consume width before right panel opens, so this .composer-footer.cf-icons .composer-left{gap:10px;overflow-x:auto;overflow-y:hidden;}
must be container-driven and independent of panel state. */ .composer-footer.cf-icons .composer-left > *{flex-shrink:0;}
.composer-workspace-label, .composer-footer.cf-icons .composer-profile-wrap,
#composerWorkspaceLabel, .composer-footer.cf-icons .composer-ws-wrap{flex:0 0 auto;}
.composer-model-label, .composer-footer.cf-icons .composer-profile-label,
.composer-model-chevron, .composer-footer.cf-icons .composer-profile-chevron,
#composerModelLabel{display:none;} .composer-footer.cf-icons #composerProfileLabel{display:none;}
.composer-footer.cf-icons .composer-profile-chip{box-sizing:border-box;width:44px;height:44px;max-width:44px;min-width:44px;min-height:44px;padding:0;justify-content:center;gap:0;font-size:11px;}
.composer-footer.cf-icons .composer-workspace-label,
.composer-footer.cf-icons #composerWorkspaceLabel{display:none;}
.composer-footer.cf-icons .composer-workspace-chip{padding-left:8px;padding-right:8px;gap:2px;}
.composer-footer.cf-icons .composer-model-label,
.composer-footer.cf-icons #composerModelLabel{display:none;}
.composer-footer.cf-icons .composer-model-chip{max-width:none;padding-left:10px;padding-right:8px;gap:4px;}
.composer-footer.cf-icons #btnSavedPrompts,
.composer-footer.cf-icons .saved-prompts-popup{display:none!important;}
.composer-footer.cf-icons .composer-divider{display:none;}
.composer-workspace-chip, .composer-footer.cf-burger .composer-left{gap:8px;}
.composer-model-chip{max-width:52px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;} .composer-footer.cf-burger .composer-workspace-group{box-sizing:border-box;flex:0 0 44px;width:44px;min-width:44px;max-width:44px;min-height:44px;overflow:hidden;border:none;background:transparent;border-radius:8px;}
.composer-workspace-group{min-height:44px;} .composer-footer.cf-burger .composer-workspace-files-btn{box-sizing:border-box;width:44px;min-width:44px;max-width:44px;padding:0;border-radius:8px;}
.composer-workspace-files-btn{min-width:44px;padding:6px 8px;} .composer-footer.cf-burger .composer-workspace-chip{display:none!important;}
.composer-divider{display:none;} .composer-footer.cf-burger .composer-left > .composer-model-wrap,
.ctx-indicator-wrap{display:none!important;} .composer-footer.cf-burger .provider-quota-chip,
.ctx-indicator-wrap{display:none!important;} .composer-footer.cf-burger .composer-left > .composer-reasoning-wrap,
.provider-quota-chip{display:none!important;} .composer-footer.cf-burger .composer-left > .composer-toolsets-wrap{display:none!important;}
} .composer-footer.cf-burger .composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important;width:44px;height:44px;min-width:44px;min-height:44px;flex-shrink:0;}
.composer-footer.cf-burger .composer-mobile-config-panel.open{display:flex;}
@container composer-footer (max-width: 520px){ .composer-footer.cf-burger .composer-mobile-config-action{flex:1 1 0;min-height:44px;min-width:0;background:var(--input-bg);border-color:var(--border);}
/* Stage 2: full icon-only for tighter widths. Container-width compaction is needed .composer-footer.cf-burger .composer-mobile-context-action{flex:1 0 100%;}
when side panels narrow the composer even though the viewport is not a phone. */ .composer-footer.cf-burger .ctx-indicator-wrap{display:none!important;}
.composer-left{gap:10px;overflow-x:auto;overflow-y:hidden;}
.composer-left > *{flex-shrink:0;}
.composer-profile-wrap,
.composer-ws-wrap{flex:0 0 auto;}
.composer-profile-label,
.composer-workspace-label,
.composer-model-label,
.composer-reasoning-label,
.composer-profile-chevron,
.composer-workspace-chevron,
.composer-model-chevron,
.composer-reasoning-chevron,
#composerProfileLabel,
#composerWorkspaceLabel,
#composerModelLabel,
#composerReasoningLabel{display:none;}
.composer-profile-chip,
.composer-model-chip,
.composer-reasoning-chip{box-sizing:border-box;width:44px;height:44px;max-width:44px;min-width:44px;min-height:44px;padding:0;justify-content:center;gap:0;font-size:11px;}
.composer-workspace-group{box-sizing:border-box;flex:0 0 44px;width:44px;min-width:44px;max-width:44px;min-height:44px;overflow:hidden;border:none;background:transparent;border-radius:8px;}
.composer-workspace-files-btn{box-sizing:border-box;width:44px;min-width:44px;max-width:44px;padding:0;border-radius:8px;}
.composer-workspace-chip{display:none!important;}
.composer-left > .composer-model-wrap,
.composer-left > .composer-reasoning-wrap,
.composer-left > .composer-toolsets-wrap{display:none!important;}
/* Saved prompts is a desktop-only composer affordance — too much for the
narrow mobile composer (Nathan 2026-06-09). Hide the button + popup. */
#btnSavedPrompts,.saved-prompts-popup{display:none!important;}
.composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important;width:44px;height:44px;min-width:44px;min-height:44px;flex-shrink:0;}
.composer-mobile-config-panel.open{display:flex;}
.composer-mobile-config-action{flex:1 1 0;min-height:44px;min-width:0;background:var(--input-bg);border-color:var(--border);}
.composer-mobile-context-action{flex:1 0 100%;}
.composer-divider{display:none;}
}
/* Show session-toolsets chip ONLY when the composer-footer container is wide /* Show session-toolsets chip ONLY when the composer-footer container is wide
enough to comfortably accommodate it (workspace+model+reasoning+profile+ enough to comfortably accommodate it (workspace+model+reasoning+profile+
+165 -24
View File
@@ -1624,7 +1624,7 @@ function _providerQuotaIndicatorText(status){
if(accountLimits&&Array.isArray(accountLimits.windows)&&accountLimits.windows.length){ if(accountLimits&&Array.isArray(accountLimits.windows)&&accountLimits.windows.length){
const w=accountLimits.windows.find(x=>x&&Number.isFinite(Number(x.remaining_percent)))||accountLimits.windows[0]; const w=accountLimits.windows.find(x=>x&&Number.isFinite(Number(x.remaining_percent)))||accountLimits.windows[0];
const remaining=_formatQuotaPercentShort(w&&w.remaining_percent); const remaining=_formatQuotaPercentShort(w&&w.remaining_percent);
if(remaining) return {label:provider+' '+remaining, title:(status.message||'Provider usage loaded')+' — '+remaining+' remaining'}; if(remaining) return {label:remaining, title:provider+' — '+(status.message||'Provider usage loaded')+' — '+remaining+' remaining'};
} }
const quota=status.quota||null; const quota=status.quota||null;
if(quota){ if(quota){
@@ -1635,7 +1635,7 @@ function _providerQuotaIndicatorText(status){
const parts=[]; const parts=[];
if(used) parts.push('used '+used); if(used) parts.push('used '+used);
if(limit) parts.push('limit '+limit); if(limit) parts.push('limit '+limit);
return {label:provider+' '+remaining, title:(status.message||'Provider quota loaded')+(parts.length?' — '+parts.join(' · '):'')}; return {label:remaining, title:provider+' — '+(status.message||'Provider quota loaded')+(parts.length?' — '+parts.join(' · '):'')};
} }
} }
return null; return null;
@@ -1643,14 +1643,17 @@ function _providerQuotaIndicatorText(status){
function renderProviderQuotaIndicator(status){ function renderProviderQuotaIndicator(status){
const chip=$('providerQuotaChip'); const chip=$('providerQuotaChip');
const label=$('providerQuotaChipLabel'); const label=$('providerQuotaChipLabel');
const mobileAction=$('composerMobileQuotaAction');
const mobileLabel=$('composerMobileQuotaLabel');
if(!chip||!label) return; if(!chip||!label) return;
// Hide entirely when the user has disabled the ambient quota chip in Settings. // Hide entirely when the user has disabled the ambient quota chip in Settings.
// Default is off (window._showQuotaChip defaults to false in boot.js) so users // Boot defaults this on; an explicit false preference suppresses it.
// never see the chip unless they opt in.
if(window._showQuotaChip!==true){ if(window._showQuotaChip!==true){
chip.hidden=true; chip.hidden=true;
label.textContent=''; label.textContent='';
chip.removeAttribute('title'); chip.removeAttribute('title');
if(mobileAction){mobileAction.style.display='none';mobileAction.removeAttribute('title');}
if(mobileLabel) mobileLabel.textContent='';
return; return;
} }
const text=_providerQuotaIndicatorText(status); const text=_providerQuotaIndicatorText(status);
@@ -1658,11 +1661,15 @@ function renderProviderQuotaIndicator(status){
chip.hidden=true; chip.hidden=true;
label.textContent=''; label.textContent='';
chip.removeAttribute('title'); chip.removeAttribute('title');
if(mobileAction){mobileAction.style.display='none';mobileAction.removeAttribute('title');}
if(mobileLabel) mobileLabel.textContent='';
return; return;
} }
label.textContent=text.label; label.textContent=text.label;
chip.title=text.title; chip.title=text.title;
chip.hidden=false; chip.hidden=false;
if(mobileAction){mobileAction.style.display='';mobileAction.title=text.title;}
if(mobileLabel) mobileLabel.textContent=text.label;
} }
async function refreshProviderQuotaIndicator(){ async function refreshProviderQuotaIndicator(){
// Short-circuit before the fetch when the chip is disabled — no point asking // Short-circuit before the fetch when the chip is disabled — no point asking
@@ -1670,6 +1677,10 @@ async function refreshProviderQuotaIndicator(){
if(window._showQuotaChip!==true){ if(window._showQuotaChip!==true){
const chip=$('providerQuotaChip'); const chip=$('providerQuotaChip');
if(chip){chip.hidden=true;chip.removeAttribute('title');} if(chip){chip.hidden=true;chip.removeAttribute('title');}
const mobileAction=$('composerMobileQuotaAction');
if(mobileAction){mobileAction.style.display='none';mobileAction.removeAttribute('title');}
const mobileLabel=$('composerMobileQuotaLabel');
if(mobileLabel) mobileLabel.textContent='';
return; return;
} }
if(_providerQuotaRefreshInFlight) return; if(_providerQuotaRefreshInFlight) return;
@@ -2385,6 +2396,37 @@ function _getConfiguredModelBadge(modelId,badgeMap,providerId){
return matches[0]; return matches[0];
} }
function _compactComposerModelChipLabel(modelId,labelText){
const id=String(modelId||'').trim();
const raw=String(labelText||'').trim();
if(!raw) return getModelLabel(id);
const idLower=id.toLowerCase();
const rawLower=raw.toLowerCase();
const slash=id.indexOf('/');
if(slash>0){
const provider=id.slice(0,slash).toLowerCase();
if(rawLower.startsWith(provider+'/')){
return raw.slice(provider.length+1).trim();
}
}
if(id&&rawLower===idLower&&raw.includes('/')){
return raw.slice(raw.indexOf('/')+1).trim();
}
if(raw.includes('/') && !/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)){
const parts=raw.split('/').map(s=>s.trim()).filter(Boolean);
if(parts.length>=2){
const tail=parts[parts.length-1];
const tailLower=tail.toLowerCase();
if(idLower && (tailLower===idLower || idLower.endsWith('/'+tailLower))) return tail;
if(parts.length===2){
const leadLower=parts[0].toLowerCase();
if(tailLower.startsWith(leadLower+'-')) return tail;
}
}
}
return raw;
}
function syncModelChip(){ function syncModelChip(){
const sel=$('modelSelect'); const sel=$('modelSelect');
const chip=$('composerModelChip'); const chip=$('composerModelChip');
@@ -2402,8 +2444,9 @@ function syncModelChip(){
} }
const opt=_selectedModelOption(); const opt=_selectedModelOption();
const text=opt?opt.textContent:getModelLabel(sel.value||''); const text=opt?opt.textContent:getModelLabel(sel.value||'');
const compactText=_compactComposerModelChipLabel(sel.value||'', text);
const gatewayRouting=_latestGatewayRoutingForSession(S.session); const gatewayRouting=_latestGatewayRoutingForSession(S.session);
const displayText=_formatGatewayModelLabel(sel.value||'',text,gatewayRouting)||text; const displayText=_formatGatewayModelLabel(sel.value||'',compactText,gatewayRouting)||compactText;
label.textContent=displayText; label.textContent=displayText;
if(mobileLabel) mobileLabel.textContent=displayText; if(mobileLabel) mobileLabel.textContent=displayText;
chip.title=gatewayRouting?`${sel.value||'Conversation model'} ${_gatewayRoutingLabel(gatewayRouting)}`:(sel.value||'Conversation model'); chip.title=gatewayRouting?`${sel.value||'Conversation model'} ${_gatewayRoutingLabel(gatewayRouting)}`:(sel.value||'Conversation model');
@@ -3220,6 +3263,79 @@ window.addEventListener('resize',()=>{
} }
}); });
// ── Fit-based composer footer collapse ──────────────────────────────────────
// Stage classes on .composer-footer:
// (none) full labels · .cf-icons icon chips · .cf-icons.cf-burger hamburger.
let _composerFitScheduled=false;
let _composerFitResizeObserver=null;
let _composerFitMutationObserver=null;
let _composerFitObservedFooter=null;
let _composerFitResizeListenerBound=false;
function _fitComposerFooter(){
const footer=document.querySelector('.composer-footer');
if(!footer) return;
const left=footer.querySelector('.composer-left');
if(!left) return;
if(!left.clientWidth) return;
const overflows=function(){return left.scrollWidth>left.clientWidth+1;};
footer.classList.remove('cf-icons','cf-burger');
if(!overflows()) return;
footer.classList.add('cf-icons');
if(!overflows()) return;
footer.classList.add('cf-burger');
}
window._fitComposerFooter=_fitComposerFooter;
function _scheduleComposerFit(){
if(_composerFitScheduled) return;
_composerFitScheduled=true;
requestAnimationFrame(function(){
_composerFitScheduled=false;
try{_fitComposerFooter();}catch(_){ }
});
}
window._scheduleComposerFit=_scheduleComposerFit;
function _initComposerFooterFit(){
const footer=document.querySelector('.composer-footer');
const left=footer&&footer.querySelector('.composer-left');
if(!footer||!left) return;
_scheduleComposerFit();
if(_composerFitObservedFooter===footer) return;
if(_composerFitResizeObserver){try{_composerFitResizeObserver.disconnect();}catch(_){ }}
if(_composerFitMutationObserver){try{_composerFitMutationObserver.disconnect();}catch(_){ }}
_composerFitResizeObserver=null;
_composerFitMutationObserver=null;
_composerFitObservedFooter=footer;
if(window.ResizeObserver){
try{
_composerFitResizeObserver=new ResizeObserver(_scheduleComposerFit);
_composerFitResizeObserver.observe(footer);
}catch(_){ }
}
if(window.MutationObserver){
try{
_composerFitMutationObserver=new MutationObserver(_scheduleComposerFit);
_composerFitMutationObserver.observe(left,{
childList:true,subtree:true,characterData:true,
attributes:true,attributeFilter:['class','style','hidden']
});
}catch(_){ }
}
if(!_composerFitResizeListenerBound){
window.addEventListener('resize',_scheduleComposerFit);
_composerFitResizeListenerBound=true;
}
}
window._initComposerFooterFit=_initComposerFooterFit;
if(document.readyState==='loading'){
document.addEventListener('DOMContentLoaded',_initComposerFooterFit);
}else{
_initComposerFooterFit();
}
// ── Reasoning effort chip ──────────────────────────────────────────────────── // ── Reasoning effort chip ────────────────────────────────────────────────────
let _currentReasoningEffort=null; let _currentReasoningEffort=null;
let _currentReasoningEffortsSupported=null; let _currentReasoningEffortsSupported=null;
@@ -3231,7 +3347,12 @@ function _normalizeReasoningEffort(eff){
function _formatReasoningEffortLabel(effort){ function _formatReasoningEffortLabel(effort){
if(effort==='none') return 'None'; if(effort==='none') return 'None';
if(!effort) return 'Default'; if(!effort) return 'Default';
return effort; if(effort==='minimal') return 'Minimal';
if(effort==='low') return 'Low';
if(effort==='medium') return 'Medium';
if(effort==='high') return 'High';
if(effort==='xhigh') return 'XHigh';
return effort.charAt(0).toUpperCase()+effort.slice(1);
} }
function _reasoningEffortContext(){ function _reasoningEffortContext(){
@@ -3304,7 +3425,9 @@ function _applyReasoningChip(eff){
if(chip){ if(chip){
const inactive=!effort||effort==='none'; const inactive=!effort||effort==='none';
chip.classList.toggle('inactive',inactive); chip.classList.toggle('inactive',inactive);
chip.title='Reasoning effort: '+text; const labelText='Reasoning effort: '+text;
chip.title=labelText;
chip.setAttribute('aria-label',labelText);
} }
if(mobileAction) mobileAction.classList.toggle('inactive',!effort||effort==='none'); if(mobileAction) mobileAction.classList.toggle('inactive',!effort||effort==='none');
_highlightReasoningOption(effort); _highlightReasoningOption(effort);
@@ -3790,6 +3913,18 @@ function closeMobileComposerConfig(){
if(typeof closeWsDropdown==='function') closeWsDropdown(); if(typeof closeWsDropdown==='function') closeWsDropdown();
} }
function openMobileComposerConfig(){
const panel=$('composerMobileConfigPanel');
if(!panel) return;
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
panel.classList.add('open');
_syncMobileComposerConfigButton(true);
}
function toggleMobileComposerConfig(){ function toggleMobileComposerConfig(){
const panel=$('composerMobileConfigPanel'); const panel=$('composerMobileConfigPanel');
if(!panel) return; if(!panel) return;
@@ -3801,15 +3936,23 @@ function toggleMobileComposerConfig(){
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown(); if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
return; return;
} }
if(typeof closeProfileDropdown==='function') closeProfileDropdown(); openMobileComposerConfig();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
panel.classList.add('open');
_syncMobileComposerConfigButton(true);
} }
function openComposerContextMenu(e){
if(e){
e.preventDefault();
e.stopPropagation();
}
const tooltip=$('ctxTooltip');
if(tooltip){
tooltip.classList.remove('ctx-tooltip-active');
tooltip.setAttribute('aria-hidden','true');
}
openMobileComposerConfig();
}
window.openComposerContextMenu=openComposerContextMenu;
document.addEventListener('click',function(e){ document.addEventListener('click',function(e){
if( if(
e.target.closest('#composerMobileConfigBtn') || e.target.closest('#composerMobileConfigBtn') ||
@@ -4469,7 +4612,7 @@ function _clearActivityElapsedTimer(){
_activityElapsedTimerGroup=null; _activityElapsedTimerGroup=null;
} }
const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, reasoning, and context settings'; const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, quota, reasoning, and context settings';
function _setCtxCompressButton(btn,text){ function _setCtxCompressButton(btn,text){
if(!btn)return; if(!btn)return;
@@ -4699,20 +4842,16 @@ function _syncCtxIndicator(usage){
} }
// ── Touch support: toggle context tooltip on tap (#524) ── // ── Touch support: toggle context tooltip on tap (#524) ──
// On mobile, hover doesn't work — allow tap on the context ring button // Hover/focus still exposes the compact tooltip, but a click/tap now opens the
// to toggle the tooltip visibility so the compress affordance is reachable. // shared composer config menu used by the phone footer so the richer context
// details and compress action have one interaction path.
document.addEventListener('DOMContentLoaded',function(){ document.addEventListener('DOMContentLoaded',function(){
const wrap=document.getElementById('ctxIndicatorWrap'); const wrap=document.getElementById('ctxIndicatorWrap');
const tooltip=document.getElementById('ctxTooltip'); const tooltip=document.getElementById('ctxTooltip');
if(!wrap||!tooltip)return; if(!wrap||!tooltip)return;
const btn=document.getElementById('ctxIndicator'); const btn=document.getElementById('ctxIndicator');
if(!btn)return; if(!btn)return;
btn.addEventListener('click',function(e){ btn.addEventListener('click',openComposerContextMenu);
e.stopPropagation();
const isOpen=tooltip.classList.contains('ctx-tooltip-active');
tooltip.classList.toggle('ctx-tooltip-active',!isOpen);
tooltip.setAttribute('aria-hidden',String(isOpen));
});
// Close on outside tap // Close on outside tap
document.addEventListener('click',function(){ document.addEventListener('click',function(){
tooltip.classList.remove('ctx-tooltip-active'); tooltip.classList.remove('ctx-tooltip-active');
@@ -5002,7 +5141,9 @@ function _gatewayRoutingLabel(routing){
function _formatGatewayModelLabel(modelId,labelText,routing){ function _formatGatewayModelLabel(modelId,labelText,routing){
if(!routing)return''; if(!routing)return'';
const usedModel=String(routing.used_model||'').trim(); const usedModel=String(routing.used_model||'').trim();
const base=usedModel?getModelLabel(usedModel):(labelText||getModelLabel(modelId)); const base=usedModel
?_compactComposerModelChipLabel(usedModel,getModelLabel(usedModel))
:_compactComposerModelChipLabel(modelId,labelText||getModelLabel(modelId));
const via=_gatewayRoutingLabel(routing); const via=_gatewayRoutingLabel(routing);
return via?`${base} ${via}`:base; return via?`${base} ${via}`:base;
} }
@@ -3,7 +3,7 @@
The chip must: The chip must:
* Be hidden by default (CSS base rule). * Be hidden by default (CSS base rule).
* Be shown only at wide composer-footer widths (>= 1100px container query). * Be shown only at wide composer-footer widths (>= 1100px container query).
* Stay hidden on mobile (@media max-width:640px and @container 520px). * Stay hidden on mobile (@media max-width:640px and the .cf-burger stage).
* Have its visibility controlled by CSS, NOT by JS (single source of truth). * Have its visibility controlled by CSS, NOT by JS (single source of truth).
* Continue to track state through _applyToolsetsChip() so /api/session/toolsets * Continue to track state through _applyToolsetsChip() so /api/session/toolsets
keeps working for scripted callers regardless of UI visibility. keeps working for scripted callers regardless of UI visibility.
@@ -12,7 +12,7 @@ import re
def _src(name: str) -> str: def _src(name: str) -> str:
with open(f"static/{name}") as f: with open(f"static/{name}", encoding="utf-8") as f:
return f.read() return f.read()
@@ -47,16 +47,15 @@ class TestToolsetsChipResponsiveCSS:
"that shows .composer-toolsets-wrap with display:block or display:flex" "that shows .composer-toolsets-wrap with display:block or display:flex"
) )
def test_narrow_container_query_keeps_hiding(self): def test_burger_stage_keeps_hiding(self):
"""The existing @container (max-width: 520px) rule must still hide the chip.""" """The fit-engine hamburger stage must still hide the chip."""
css = _src("style.css") css = _src("style.css")
# Look for the existing 520px rule that already hid composer-toolsets-wrap
m = re.search( m = re.search(
r'@container\s+composer-footer\s*\(\s*max-width:\s*520px\s*\).*?\.composer-toolsets-wrap\s*\{\s*display:\s*none\s*!important', r'\.composer-footer\.cf-burger\b.*?\.composer-toolsets-wrap\s*\{\s*display:\s*none\s*!important',
css, re.DOTALL, css, re.DOTALL,
) )
assert m, ( assert m, (
"@container composer-footer (max-width: 520px) must continue to " ".composer-footer.cf-burger must continue to "
"hide .composer-toolsets-wrap with !important" "hide .composer-toolsets-wrap with !important"
) )
@@ -143,7 +142,7 @@ class TestToolsetsAPIStillWorks:
"""The api/session/toolsets endpoint must still be registered.""" """The api/session/toolsets endpoint must still be registered."""
# Check api/routes.py for the endpoint # Check api/routes.py for the endpoint
try: try:
with open("api/routes.py") as f: with open("api/routes.py", encoding="utf-8") as f:
src = f.read() src = f.read()
except FileNotFoundError: except FileNotFoundError:
# If routes.py is named differently, search # If routes.py is named differently, search
@@ -30,9 +30,13 @@ const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8'); const src = fs.readFileSync(process.argv[2], 'utf8');
function makeEl() { function makeEl() {
const attrs = {};
return { return {
style: {}, dataset: {}, title: '', textContent: '', value: '', style: {}, dataset: {}, title: '', textContent: '', value: '',
classList: { add(){}, remove(){}, toggle(){}, contains(){return false} }, classList: { add(){}, remove(){}, toggle(){}, contains(){return false} },
setAttribute(name, value){ attrs[name] = String(value); },
getAttribute(name){ return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; },
removeAttribute(name){ delete attrs[name]; },
querySelectorAll(){return []}, querySelector(){return null}, querySelectorAll(){return []}, querySelector(){return null},
getBoundingClientRect(){return {left:0,top:0,width:0,height:0}}, getBoundingClientRect(){return {left:0,top:0,width:0,height:0}},
}; };
+128 -73
View File
@@ -351,92 +351,89 @@ def _container_media_block(css: str, media_query: str):
return "" return ""
def test_composer_controls_switch_to_icon_only_by_container_width(): def test_composer_controls_switch_to_fit_stage_classes():
"""Composer controls should progressively compact based on footer width.""" """Composer controls should progressively compact based on measured overflow."""
assert re.search(r'\.composer-footer\s*\{[^}]*container-type:inline-size[^}]*container-name:composer-footer[^}]*\}', CSS), \ assert re.search(r'\.composer-footer\s*\{[^}]*container-type:inline-size[^}]*container-name:composer-footer[^}]*\}', CSS), \
".composer-footer should define container-type:inline-size and container-name:composer-footer" ".composer-footer should define container-type:inline-size and container-name:composer-footer"
compact_700 = _container_query_block(CSS, "composer-footer (max-width: 700px)") assert "@container composer-footer (max-width: 700px)" not in CSS
assert compact_700, "Expected composer mid-width compact rules at @container composer-footer (max-width: 700px)" assert "@container composer-footer (max-width: 520px)" not in CSS
for selector in ( ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
".composer-workspace-label", fit_body = _js_function_body(ui_js, "_fitComposerFooter")
".composer-model-label", assert "scrollWidth" in fit_body and "clientWidth" in fit_body, \
".composer-model-chevron", "_fitComposerFooter() should measure actual left-cluster overflow"
"#composerWorkspaceLabel", assert "cf-icons" in fit_body and "cf-burger" in fit_body, \
"#composerModelLabel", "_fitComposerFooter() should toggle the compact stage classes"
".composer-workspace-chip",
".composer-model-chip",
".composer-divider",
):
assert selector in compact_700, f"{selector} should be present in the 700px composer compact block"
assert "display:none" in compact_700
assert "max-width:52px" in compact_700
# Ensure this first stage does not prematurely remove profile/reasoning labels.
assert ".composer-profile-label" not in compact_700
assert ".composer-reasoning-label" not in compact_700
assert ".composer-profile-chevron" not in compact_700
assert ".composer-reasoning-chevron" not in compact_700
compact_520 = _container_query_block(CSS, "composer-footer (max-width: 520px)")
assert compact_520, "Expected full composer icon-only rules at @container composer-footer (max-width: 520px)"
for selector in ( for selector in (
".composer-profile-label", ".composer-footer.cf-icons .composer-profile-label",
".composer-workspace-label", ".composer-footer.cf-icons .composer-profile-chevron",
".composer-model-label", ".composer-footer.cf-icons #composerProfileLabel",
".composer-reasoning-label", ".composer-footer.cf-icons .composer-workspace-label",
".composer-profile-chevron", ".composer-footer.cf-icons #composerWorkspaceLabel",
".composer-workspace-chevron", ".composer-footer.cf-icons .composer-model-label",
".composer-model-chevron", ".composer-footer.cf-icons #composerModelLabel",
".composer-reasoning-chevron", ".composer-footer.cf-icons .composer-profile-chip",
"#composerProfileLabel", ".composer-footer.cf-icons .composer-model-chip",
"#composerWorkspaceLabel", ".composer-footer.cf-icons .composer-divider",
"#composerModelLabel",
"#composerReasoningLabel",
".composer-model-chip",
".composer-profile-chip",
".composer-reasoning-chip",
): ):
assert selector in compact_520, f"{selector} should be present in the 520px composer compact block" assert selector in CSS, f"{selector} should be present in the .cf-icons rules"
assert "width:44px" in compact_520 assert ".composer-footer.cf-icons .composer-profile-chip{box-sizing:border-box;width:44px" in CSS, \
assert "display:none" in compact_520 ".cf-icons should collapse the profile chip to an icon-sized control"
assert ".composer-workspace-chip{display:none!important" in compact_520.replace(" ", ""), \ assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in CSS.replace(" ", ""), \
"520px container compact mode must remove the blank workspace switch slot" ".cf-icons should keep the workspace switch visible rather than blanking it"
assert ".composer-left>*{flex-shrink:0" in compact_520.replace(" ", ""), \
"520px container compact mode must stop controls from shrinking into each other" for selector in (
assert ".composer-mobile-config-btn" in compact_520 and "display:inline-flex!important" in compact_520, \ ".composer-footer.cf-burger .composer-workspace-group",
"520px container compact mode must expose the mobile config button even when viewport is wider than 640px" ".composer-footer.cf-burger .composer-workspace-files-btn",
".composer-footer.cf-burger .composer-workspace-chip",
".composer-footer.cf-burger .composer-left > .composer-model-wrap",
".composer-footer.cf-burger .provider-quota-chip",
".composer-footer.cf-burger .composer-left > .composer-reasoning-wrap",
".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap",
".composer-footer.cf-burger .composer-mobile-config-btn",
".composer-footer.cf-burger .composer-mobile-config-panel.open",
".composer-footer.cf-burger .composer-mobile-context-action",
".composer-footer.cf-burger .ctx-indicator-wrap",
):
assert selector in CSS, f"{selector} should be present in the .cf-burger rules"
assert ".composer-footer.cf-burger .composer-workspace-chip{display:none!important".replace(" ", "") in CSS.replace(" ", ""), \
".cf-burger must remove the blank workspace switch slot"
assert ".composer-footer.cf-burger .provider-quota-chip" in CSS and ".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap{display:none!important" in CSS, \
".cf-burger must fold the inline quota chip into the shared config menu"
assert ".composer-footer.cf-burger .composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important" in CSS, \
".cf-burger must expose the config button even on wider viewports"
# Regression intent: # Regression intent:
# - this container rule should not depend on right-panel open/closed state. # - this measured rule should not depend on right-panel open/closed state.
# - left-sidebar-only constriction must still collapse composer controls together. # - left-sidebar-only constriction must still collapse composer controls together.
assert ".layout:not(.workspace-panel-collapsed)" not in compact_700, \ assert ".layout:not(.workspace-panel-collapsed)" not in CSS, \
"composer-footer compact rule should be state-agnostic (left sidebar + closed right panel case included)" "composer-footer compact stages should be state-agnostic"
assert ".layout:not(.workspace-panel-collapsed)" not in compact_520, \
"composer-footer compact rule should be state-agnostic (left sidebar + closed right panel case included)"
def test_composer_700px_workspace_switch_does_not_become_blank_chip(): def test_composer_icon_stage_workspace_switch_does_not_become_blank_chip():
"""The 700px container state may hide the workspace label, but needs a switch affordance.""" """The icon stage may hide workspace text, but keeps a visible switch affordance."""
compact_700 = _container_query_block(CSS, "composer-footer (max-width: 700px)") css_ns = CSS.replace(" ", "")
assert compact_700, "Expected composer mid-width compact rules at @container composer-footer (max-width: 700px)" assert ".composer-footer.cf-icons .composer-workspace-label" in CSS
assert ".composer-footer.cf-icons #composerWorkspaceLabel" in CSS
workspace_label = _declarations(_rule_body(compact_700, ".composer-workspace-label")) assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in css_ns, \
workspace_chip = _optional_declarations(compact_700, ".composer-workspace-chip") ".cf-icons must not hide the visible workspace switch chip"
workspace_chevron = _optional_declarations(compact_700, ".composer-workspace-chevron") assert ".composer-footer.cf-icons .composer-workspace-chevron{display:none" not in css_ns, \
mobile_config = _optional_declarations(compact_700, ".composer-mobile-config-btn") ".cf-icons must not leave the visible workspace switch chip without its chevron"
assert _display_hidden(workspace_label), \
"700px container state should hide the long workspace label before tighter mobile rules"
if not _display_hidden(workspace_chip) and not _display_inline_flex(mobile_config):
assert not _display_hidden(workspace_chevron), \
"700px container state must not leave the visible workspace switch chip without a label or chevron"
def test_composer_compact_switch_is_not_viewport_only(): def test_composer_compact_switch_is_not_viewport_only():
"""Compact controls should be container-triggered, not bound to viewport width alone.""" """Compact controls should be content-triggered, not bound to viewport width alone."""
assert "composer-footer (max-width: 700px)" in CSS, \ assert ".composer-footer.cf-icons" in CSS, \
"Container-query breakpoint should track composer footer width" "Icon-chip stage should be expressed as the .cf-icons class"
assert "composer-footer (max-width: 520px)" in CSS, \ assert ".composer-footer.cf-burger" in CSS, \
"Container-query second-stage breakpoint should track composer footer width" "Hamburger stage should be expressed as the .cf-burger class"
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "_fitComposerFooter" in ui_js and "scrollWidth" in ui_js, \
"Composer compacting should be driven by measured overflow"
assert "ResizeObserver" in ui_js and "MutationObserver" in ui_js, \
"Composer fitting should rerun when footer size or chip content changes"
assert "composer-footer (max-width: 700px)" not in CSS
assert "composer-footer (max-width: 520px)" not in CSS
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*860px\)', CSS) is None, \ assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*860px\)', CSS) is None, \
"Full icon-only should not be tied to a 860px threshold any more" "Full icon-only should not be tied to a 860px threshold any more"
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*1000px\)', CSS) is None, \ assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*1000px\)', CSS) is None, \
@@ -576,6 +573,17 @@ def test_mobile_sidebar_opens_as_full_screen_surface_with_panel_rail():
) )
def test_compact_titlebar_keeps_hamburger_available():
"""Compact app chrome must keep the titlebar menu reachable."""
compact_css = "\n".join(_max_width_media_blocks(900))
assert re.search(r'\.app-titlebar-hamburger,\s*\.app-titlebar-spacer\{[^}]*display:\s*flex', compact_css), (
"Compact titlebar should expose the hamburger before true phone width"
)
assert ".rightpanel{display:none}" in compact_css.replace(" ", ""), (
"The compact titlebar breakpoint should match the hidden workspace-panel breakpoint"
)
def test_mobile_rail_click_opens_full_screen_panel_drawer(): def test_mobile_rail_click_opens_full_screen_panel_drawer():
"""Rail clicks on phone should keep the full-screen drawer open for panel switching.""" """Rail clicks on phone should keep the full-screen drawer open for panel switching."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8") panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
@@ -1217,6 +1225,8 @@ def test_model_and_reasoning_controls_live_in_mobile_overflow_panel():
panel_html = HTML[panel_start:panel_end] panel_html = HTML[panel_start:panel_end]
assert 'id="composerMobileModelAction"' in panel_html, \ assert 'id="composerMobileModelAction"' in panel_html, \
"mobile model action must be inside the overflow panel" "mobile model action must be inside the overflow panel"
assert 'id="composerMobileQuotaAction"' in panel_html, \
"mobile quota action must be inside the overflow panel"
assert 'id="composerMobileReasoningAction"' in panel_html, \ assert 'id="composerMobileReasoningAction"' in panel_html, \
"mobile reasoning action must be inside the overflow panel" "mobile reasoning action must be inside the overflow panel"
assert 'onclick="toggleModelDropdown()"' in panel_html, \ assert 'onclick="toggleModelDropdown()"' in panel_html, \
@@ -1225,11 +1235,15 @@ def test_model_and_reasoning_controls_live_in_mobile_overflow_panel():
"mobile reasoning action must reuse the existing reasoning dropdown" "mobile reasoning action must reuse the existing reasoning dropdown"
assert 'id="composerMobileModelLabel"' in panel_html, \ assert 'id="composerMobileModelLabel"' in panel_html, \
"mobile model action must expose the selected model label" "mobile model action must expose the selected model label"
assert 'id="composerMobileQuotaLabel"' in panel_html, \
"mobile quota action must expose the selected quota label"
assert 'id="composerMobileReasoningLabel"' in panel_html, \ assert 'id="composerMobileReasoningLabel"' in panel_html, \
"mobile reasoning action must expose the selected reasoning label" "mobile reasoning action must expose the selected reasoning label"
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8") ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "composerMobileModelAction" in ui_js, \ assert "composerMobileModelAction" in ui_js, \
"model dropdown positioning/click handling must know the mobile model action" "model dropdown positioning/click handling must know the mobile model action"
assert "composerMobileQuotaAction" in ui_js, \
"quota sync must know the mobile quota action"
assert "composerMobileReasoningAction" in ui_js, \ assert "composerMobileReasoningAction" in ui_js, \
"reasoning dropdown positioning/click handling must know the mobile reasoning action" "reasoning dropdown positioning/click handling must know the mobile reasoning action"
@@ -1242,6 +1256,20 @@ def test_model_and_reasoning_controls_live_in_mobile_overflow_panel():
"mobile overflow panel must size the model/reasoning actions" "mobile overflow panel must size the model/reasoning actions"
def test_mobile_overflow_panel_quota_order_matches_desktop_sequence():
"""The mobile overflow panel should keep the same shared control order as desktop."""
panel_start = HTML.index('id="composerMobileConfigPanel"')
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
panel_html = HTML[panel_start:panel_end]
workspace_idx = panel_html.index('id="composerMobileWorkspaceAction"')
model_idx = panel_html.index('id="composerMobileModelAction"')
quota_idx = panel_html.index('id="composerMobileQuotaAction"')
reasoning_idx = panel_html.index('id="composerMobileReasoningAction"')
context_idx = panel_html.index('id="composerMobileContextAction"')
assert workspace_idx < model_idx < quota_idx < reasoning_idx < context_idx, \
"mobile control order should mirror the desktop/shared control sequence"
def test_model_and_reasoning_dropdowns_use_mobile_panel_anchors(): def test_model_and_reasoning_dropdowns_use_mobile_panel_anchors():
"""Model/reasoning dropdowns must anchor to mobile actions while the overflow is open.""" """Model/reasoning dropdowns must anchor to mobile actions while the overflow is open."""
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8") ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
@@ -1322,6 +1350,32 @@ def test_context_details_live_in_mobile_overflow_panel():
"mobile compress affordance should be compact inside the context row" "mobile compress affordance should be compact inside the context row"
def test_context_indicator_click_opens_shared_mobile_config_menu():
"""The desktop context ring should open the same menu used by phone mode."""
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "function openMobileComposerConfig()" in ui_js, \
"mobile config open path should be reusable outside the phone button"
assert "function openComposerContextMenu(e)" in ui_js, \
"context indicator needs a named click path into the shared config menu"
context_menu_body = _js_function_body(ui_js, "openComposerContextMenu")
for expected in (
"e.preventDefault()",
"e.stopPropagation()",
"ctxTooltip",
"openMobileComposerConfig()",
):
assert expected in context_menu_body, \
f"context click should open the shared menu without leaving tooltip state behind ({expected})"
assert "btn.addEventListener('click',openComposerContextMenu)" in ui_js, \
"context indicator click must open the shared composer config menu"
panel_open = _declarations(_rule_body(CSS, ".composer-mobile-config-panel.open"))
assert panel_open.get("display") == "flex", \
"the shared composer config panel must be displayable when opened outside phone CSS"
def test_workspace_control_lives_in_mobile_overflow_panel(): def test_workspace_control_lives_in_mobile_overflow_panel():
"""Workspace switching must stay reachable even when the inline switch chip is hidden.""" """Workspace switching must stay reachable even when the inline switch chip is hidden."""
panel_start = HTML.index('id="composerMobileConfigPanel"') panel_start = HTML.index('id="composerMobileConfigPanel"')
@@ -1415,6 +1469,7 @@ def test_mobile_config_kickers_have_i18n_fallbacks():
for key, label in ( for key, label in (
("composer_mobile_workspace", "Workspace"), ("composer_mobile_workspace", "Workspace"),
("composer_mobile_model", "Model"), ("composer_mobile_model", "Model"),
("composer_mobile_quota", "Quota"),
("composer_mobile_reasoning", "Reasoning"), ("composer_mobile_reasoning", "Reasoning"),
("composer_mobile_context", "Context"), ("composer_mobile_context", "Context"),
): ):
+51 -11
View File
@@ -1,7 +1,7 @@
"""Regression test for #show-quota-chip-toggle — Settings toggle to opt into the ambient quota chip. """Regression test for #show-quota-chip-toggle — Settings toggle for the ambient quota chip.
Quota chip default state is now OFF (per Nathan's directive 2026-05-16, immediately Quota chip default state is ON so provider quota is visible when available, while
after the stage-371 release of #2082). Users opt in via Settings → Preferences. users can still opt out via Settings -> Preferences.
""" """
from pathlib import Path from pathlib import Path
@@ -21,11 +21,13 @@ def test_quota_chip_settings_field_present():
assert 'data-i18n="settings_desc_quota_chip"' in html assert 'data-i18n="settings_desc_quota_chip"' in html
def test_quota_chip_default_off_in_config_defaults(): def test_quota_chip_default_on_in_config_defaults():
src = CONFIG.read_text(encoding="utf-8") src = CONFIG.read_text(encoding="utf-8")
assert '"show_quota_chip": False' in src, "show_quota_chip must default to False (opt-in)" assert '"show_quota_chip": True' in src, "show_quota_chip must default to True"
# Must be in the writable settings allow-list (bool keys) # Must be in the writable settings allow-list (bool keys)
assert '"show_quota_chip",' in src, "show_quota_chip must be in _SETTINGS_BOOL_KEYS" assert '"show_quota_chip",' in src, "show_quota_chip must be in _SETTINGS_BOOL_KEYS"
assert '"show_quota_chip_opt_out": False' in src, "opt-out marker must default to False"
assert '"show_quota_chip_opt_out",' in src, "opt-out marker must be in _SETTINGS_BOOL_KEYS"
def test_quota_chip_render_short_circuits_when_disabled(): def test_quota_chip_render_short_circuits_when_disabled():
@@ -48,6 +50,8 @@ def test_quota_chip_render_short_circuits_when_disabled():
assert guard_idx < text_call_idx, ( assert guard_idx < text_call_idx, (
"Disabled-chip guard must run before the indicator-text computation" "Disabled-chip guard must run before the indicator-text computation"
) )
assert "composerMobileQuotaAction" in render_body
assert "composerMobileQuotaLabel" in render_body
# Refresher must short-circuit fetch when disabled # Refresher must short-circuit fetch when disabled
refresh_start = js.index("async function refreshProviderQuotaIndicator(){") refresh_start = js.index("async function refreshProviderQuotaIndicator(){")
@@ -57,17 +61,19 @@ def test_quota_chip_render_short_circuits_when_disabled():
assert "window._showQuotaChip!==true" in refresh_head, ( assert "window._showQuotaChip!==true" in refresh_head, (
"refreshProviderQuotaIndicator must skip the fetch when chip is disabled" "refreshProviderQuotaIndicator must skip the fetch when chip is disabled"
) )
assert "composerMobileQuotaAction" in refresh_head
assert "composerMobileQuotaLabel" in refresh_head
def test_quota_chip_boot_initializes_default_off(): def test_quota_chip_boot_initializes_default_on():
js = BOOT.read_text(encoding="utf-8") js = BOOT.read_text(encoding="utf-8")
# Both success path (reads from settings) and failure path (defaults block) # Both success path (reads from settings) and failure path (defaults block)
# must set window._showQuotaChip # must set window._showQuotaChip
assert "window._showQuotaChip=s.show_quota_chip===true" in js, ( assert "window._showQuotaChip=s.show_quota_chip!==false" in js, (
"Boot must initialize _showQuotaChip from settings.show_quota_chip" "Boot must initialize _showQuotaChip from settings.show_quota_chip, defaulting on"
) )
assert "window._showQuotaChip=false" in js, ( assert "window._showQuotaChip=true" in js, (
"Boot must default _showQuotaChip to false in the settings-fetch-failed branch" "Boot must default _showQuotaChip to true in the settings-fetch-failed branch"
) )
@@ -76,10 +82,12 @@ def test_quota_chip_panels_round_trip():
# Payload read # Payload read
assert "const showQuotaChipCb=$('settingsShowQuotaChip');" in js assert "const showQuotaChipCb=$('settingsShowQuotaChip');" in js
assert "payload.show_quota_chip=showQuotaChipCb.checked;" in js assert "payload.show_quota_chip=showQuotaChipCb.checked;" in js
assert "payload.show_quota_chip_opt_out=!showQuotaChipCb.checked;" in js
# Body assignment # Body assignment
assert "body.show_quota_chip=showQuotaChip===true;" in js assert "body.show_quota_chip=showQuotaChip===true;" in js
assert "body.show_quota_chip_opt_out=showQuotaChip!==true;" in js
# Settings panel load — checkbox is initialized from saved settings # Settings panel load — checkbox is initialized from saved settings
assert "showQuotaChipCb.checked=settings.show_quota_chip===true;" in js assert "showQuotaChipCb.checked=settings.show_quota_chip!==false;" in js
# Window-state propagation # Window-state propagation
assert "window._showQuotaChip=showQuotaChip===true;" in js assert "window._showQuotaChip=showQuotaChip===true;" in js
# Live refresh on toggle (immediate visual feedback) # Live refresh on toggle (immediate visual feedback)
@@ -90,3 +98,35 @@ def test_quota_chip_localized_in_all_locales():
js = I18N.read_text(encoding="utf-8") js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_quota_chip:") == 14, "12 locales expected" assert js.count("settings_label_quota_chip:") == 14, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 14, "12 locales expected" assert js.count("settings_desc_quota_chip:") == 14, "12 locales expected"
def test_quota_chip_migrates_old_persisted_false_without_opt_out(tmp_path, monkeypatch):
import json
import api.config as config
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"onboarding_completed": True, "show_quota_chip": False}),
encoding="utf-8",
)
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
assert config.load_settings()["show_quota_chip"] is True
def test_quota_chip_honors_explicit_post_flip_opt_out(tmp_path, monkeypatch):
import json
import api.config as config
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({
"onboarding_completed": True,
"show_quota_chip": False,
"show_quota_chip_opt_out": True,
}),
encoding="utf-8",
)
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
assert config.load_settings()["show_quota_chip"] is False
+8 -4
View File
@@ -29,8 +29,11 @@ const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8'); const src = fs.readFileSync(process.argv[2], 'utf8');
function makeEl() { function makeEl() {
return { return {
style: {}, style: {},
_attrs: {},
setAttribute(k, v){this._attrs[k] = String(v)},
getAttribute(k){return this._attrs[k]},
classList: { classList: {
_set: new Set(), _set: new Set(),
add(c){this._set.add(c)}, add(c){this._set.add(c)},
@@ -149,12 +152,13 @@ class TestChipAlwaysVisible:
def test_low_shows_chip_active(self, driver_path): def test_low_shows_chip_active(self, driver_path):
out = _apply(driver_path, "low") out = _apply(driver_path, "low")
assert out["display"] == "" assert out["display"] == ""
assert out["label"] == "low" assert out["label"] == "Low"
assert out["inactive"] is False assert out["inactive"] is False
def test_high_shows_chip_active(self, driver_path): def test_high_shows_chip_active(self, driver_path):
out = _apply(driver_path, "high") out = _apply(driver_path, "high")
assert out["display"] == "" assert out["display"] == ""
assert out["label"] == "High"
assert out["inactive"] is False assert out["inactive"] is False
@@ -178,7 +182,7 @@ class TestNormalizationEdgeCases:
# Defensive: unknown effort still shows the chip rather than hiding. # Defensive: unknown effort still shows the chip rather than hiding.
out = _apply(driver_path, "banana") out = _apply(driver_path, "banana")
assert out["display"] == "" assert out["display"] == ""
assert out["label"] == "banana" assert out["label"] == "Banana"
assert out["inactive"] is False assert out["inactive"] is False
@@ -196,4 +200,4 @@ class TestTitleAttributeAccessibility:
def test_title_has_active_label_for_high(self, driver_path): def test_title_has_active_label_for_high(self, driver_path):
out = _apply(driver_path, "high") out = _apply(driver_path, "high")
assert out["title"] == "Reasoning effort: high" assert out["title"] == "Reasoning effort: High"