v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,25 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.47.0] — 2026-04-11
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **`/skills [query]` slash command** (PR #257): Fetches from `/api/skills`, groups results by category (alphabetically), renders as a formatted assistant message. Optional query filters by name, description, or category. Shows in the `/` autocomplete dropdown. i18n for en/de/zh/zh-Hant. 1 regression test added.
|
||||||
|
- **Shared app dialogs replace native `confirm()`/`prompt()`** (PR #251, extracted from #242 by @aronprins): `showConfirmDialog()` and `showPromptDialog()` in `ui.js`, backed by `#appDialogOverlay`. Replaces all 11 native browser dialog call sites across panels.js, sessions.js, ui.js, workspace.js. Full keyboard focus trap (Tab/Escape/Enter), ARIA roles, danger mode, focus restore, mobile-responsive buttons. i18n for en/de/zh/zh-Hant. 5 new tests in `test_sprint33.py`.
|
||||||
|
- **Session `⋯` action dropdown** (PR #252, extracted from #242 by @aronprins): Replaces 5 per-row hover buttons (pin/move/archive/duplicate/delete) with a single `⋯` trigger. Menu uses `position:fixed` to avoid sidebar clipping. Full close handling: click-outside, scroll, Escape, resize-reposition. `test_sprint16.py` updated to assert the new trigger exists and old button classes are gone.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Custom provider with slash model name no longer rerouted to OpenRouter** (PR #255): `resolve_model_provider()` now returns immediately with the configured `provider`/`base_url` when `base_url` is set, before the slash-based OpenRouter heuristic runs. Fixes `google/gemma-4-26b-a4b` with `provider: custom` being silently routed to OpenRouter (401 errors). 1 regression test added. Fixes #230.
|
||||||
|
- **Android Chrome: workspace panel now closeable on mobile** (PR #256): `toggleMobileFiles()` now shows/hides the mobile overlay. New `closeMobileFiles()` helper closes the right panel with correct overlay tracking. Overlay tap-to-close calls both `closeMobileSidebar()` and `closeMobileFiles()`. Mobile-only `×` close button added to workspace panel header. Fix applied during review: `closeMobileSidebar()` now checks if the right panel is still open before hiding the overlay. Fixes #247.
|
||||||
|
- **Android Chrome: profile dropdown no longer clipped on mobile** (PR #256): `.profile-dropdown` switches to `position:fixed; top:56px; right:8px` at `max-width:900px`, escaping the `overflow-x:auto` stacking context that was making it invisible. Fixes #246.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- **Mobile layout regression suite** (PR #254): 14 static tests in `tests/test_mobile_layout.py` that run on every QA pass. Covers: CSS breakpoints at 900px/640px, right panel slide-over, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, `100dvh`, 44px touch targets, 16px textarea font. All pass against current and future master.
|
||||||
|
|
||||||
|
**645 tests (up from 624 on v0.46.0 — +21 new tests)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v0.46.0] — 2026-04-11
|
## [v0.46.0] — 2026-04-11
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||||
>
|
>
|
||||||
> Last updated: v0.46.0 (April 11, 2026) — 624 tests, 624 passing
|
> Last updated: v0.47.0 (April 11, 2026) — 645 tests, 645 passing
|
||||||
> Tests: 604 total (604 passing, 0 failures)
|
> Tests: 604 total (604 passing, 0 failures)
|
||||||
> Source: <repo>/
|
> Source: <repo>/
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
|
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
|
||||||
| v0.45.0 | Custom endpoint in new profile form | Base URL + API key fields; server-side URL validation; config.yaml merge; 9 new tests (PR #233, fixes #170) | 604 |
|
| v0.45.0 | Custom endpoint in new profile form | Base URL + API key fields; server-side URL validation; config.yaml merge; 9 new tests (PR #233, fixes #170) | 604 |
|
||||||
| v0.46.0 | Security, Docker UID/GID, model discovery, i18n, cancel fix | Credential redaction in API responses (PR #243); Docker UID/GID matching (PR #237); custom model API key discovery (PR #238); HTML entity decode + zh/zh-Hant i18n (PR #239); cancel interrupts agent (PR #244); +20 tests | 624 |
|
| v0.46.0 | Security, Docker UID/GID, model discovery, i18n, cancel fix | Credential redaction in API responses (PR #243); Docker UID/GID matching (PR #237); custom model API key discovery (PR #238); HTML entity decode + zh/zh-Hant i18n (PR #239); cancel interrupts agent (PR #244); +20 tests | 624 |
|
||||||
|
| v0.47.0 | Dialogs, session menu, skills command, mobile fixes, mobile QA | Shared app dialogs (#251); session ⋯ menu (#252); mobile QA suite (#254); custom provider slash routing fix (#255); Android Chrome mobile fixes (#256); /skills command (#257); +21 tests | 645 |
|
||||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||||
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||||
>
|
>
|
||||||
> Automated tests: 624 total (624 passing, 0 skipped, 0 known failures)
|
> Automated tests: 645 total (645 passing, 0 skipped, 0 known failures)
|
||||||
> Run: `pytest tests/ -v --timeout=60`
|
> Run: `pytest tests/ -v --timeout=60`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1708,8 +1708,8 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: v0.46.0, April 11, 2026*
|
*Last updated: v0.47.0, April 11, 2026*
|
||||||
*Total automated tests: 624 (624 passing, 0 failures)*
|
*Total automated tests: 645 (645 passing, 0 failures)*
|
||||||
*Regression gate: tests/test_regressions.py*
|
*Regression gate: tests/test_regressions.py*
|
||||||
*Run: pytest tests/ -v --timeout=60*
|
*Run: pytest tests/ -v --timeout=60*
|
||||||
*Source: <repo>/*
|
*Source: <repo>/*
|
||||||
|
|||||||
@@ -448,6 +448,11 @@ def resolve_model_provider(model_id: str) -> tuple:
|
|||||||
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
|
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
|
||||||
if config_provider and prefix == config_provider:
|
if config_provider and prefix == config_provider:
|
||||||
return bare, config_provider, config_base_url
|
return bare, config_provider, config_base_url
|
||||||
|
# If a custom endpoint base_url is configured, don't reroute through OpenRouter
|
||||||
|
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
|
||||||
|
# The user has explicitly pointed at a base_url, so trust their routing config.
|
||||||
|
if config_base_url:
|
||||||
|
return model_id, config_provider, config_base_url
|
||||||
# If prefix does NOT match config provider, the user picked a cross-provider model
|
# If prefix does NOT match config provider, the user picked a cross-provider model
|
||||||
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
||||||
# In this case always route through openrouter with the full provider/model string.
|
# In this case always route through openrouter with the full provider/model string.
|
||||||
|
|||||||
@@ -21,12 +21,37 @@ function closeMobileSidebar(){
|
|||||||
const sidebar=document.querySelector('.sidebar');
|
const sidebar=document.querySelector('.sidebar');
|
||||||
const overlay=$('mobileOverlay');
|
const overlay=$('mobileOverlay');
|
||||||
if(sidebar)sidebar.classList.remove('mobile-open');
|
if(sidebar)sidebar.classList.remove('mobile-open');
|
||||||
|
// only hide overlay if right panel is also closed
|
||||||
|
const panel=document.querySelector('.rightpanel');
|
||||||
|
if(!panel||!panel.classList.contains('mobile-open')){
|
||||||
if(overlay)overlay.classList.remove('visible');
|
if(overlay)overlay.classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function toggleMobileFiles(){
|
function toggleMobileFiles(){
|
||||||
const panel=document.querySelector('.rightpanel');
|
const panel=document.querySelector('.rightpanel');
|
||||||
|
const overlay=$('mobileOverlay');
|
||||||
if(!panel)return;
|
if(!panel)return;
|
||||||
panel.classList.toggle('mobile-open');
|
if(panel.classList.contains('mobile-open')){
|
||||||
|
panel.classList.remove('mobile-open');
|
||||||
|
// only hide overlay if left sidebar is also closed
|
||||||
|
const sidebar=document.querySelector('.sidebar');
|
||||||
|
if(!sidebar||!sidebar.classList.contains('mobile-open')){
|
||||||
|
if(overlay)overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panel.classList.add('mobile-open');
|
||||||
|
if(overlay)overlay.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeMobileFiles(){
|
||||||
|
const panel=document.querySelector('.rightpanel');
|
||||||
|
const overlay=$('mobileOverlay');
|
||||||
|
if(panel)panel.classList.remove('mobile-open');
|
||||||
|
// only hide overlay if left sidebar is also closed
|
||||||
|
const sidebar=document.querySelector('.sidebar');
|
||||||
|
if(!sidebar||!sidebar.classList.contains('mobile-open')){
|
||||||
|
if(overlay)overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function mobileSwitchPanel(name){
|
function mobileSwitchPanel(name){
|
||||||
// Switch the panel content view
|
// Switch the panel content view
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const COMMANDS=[
|
|||||||
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage},
|
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage},
|
||||||
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'},
|
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'},
|
||||||
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name'},
|
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name'},
|
||||||
|
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseCommand(text){
|
function parseCommand(text){
|
||||||
@@ -140,6 +141,49 @@ async function cmdTheme(args){
|
|||||||
showToast(t('theme_set')+themeName);
|
showToast(t('theme_set')+themeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cmdSkills(args){
|
||||||
|
try{
|
||||||
|
const data = await api('/api/skills');
|
||||||
|
let skills = data.skills || [];
|
||||||
|
if(args){
|
||||||
|
const q = args.toLowerCase();
|
||||||
|
skills = skills.filter(s =>
|
||||||
|
(s.name||'').toLowerCase().includes(q) ||
|
||||||
|
(s.description||'').toLowerCase().includes(q) ||
|
||||||
|
(s.category||'').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(!skills.length){
|
||||||
|
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
|
||||||
|
S.messages.push(msg); renderMessages(); return;
|
||||||
|
}
|
||||||
|
// Group by category
|
||||||
|
const byCategory = {};
|
||||||
|
skills.forEach(s => {
|
||||||
|
const cat = s.category || 'General';
|
||||||
|
if(!byCategory[cat]) byCategory[cat] = [];
|
||||||
|
byCategory[cat].push(s);
|
||||||
|
});
|
||||||
|
const lines = [];
|
||||||
|
for(const [cat, items] of Object.entries(byCategory).sort()){
|
||||||
|
lines.push(`**${cat}**`);
|
||||||
|
items.forEach(s => {
|
||||||
|
const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
|
||||||
|
lines.push(` \`${s.name}\`${desc}`);
|
||||||
|
});
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
const header = args
|
||||||
|
? `Skills matching "${args}" (${skills.length}):\n\n`
|
||||||
|
: `Available skills (${skills.length}):\n\n`;
|
||||||
|
S.messages.push({role:'assistant', content: header + lines.join('\n')});
|
||||||
|
renderMessages();
|
||||||
|
showToast(t('type_slash'));
|
||||||
|
}catch(e){
|
||||||
|
showToast('Failed to load skills: '+e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdPersonality(args){
|
async function cmdPersonality(args){
|
||||||
if(!S.session){showToast(t('no_active_session'));return;}
|
if(!S.session){showToast(t('no_active_session'));return;}
|
||||||
if(!args){
|
if(!args){
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const LOCALES = {
|
|||||||
cmd_usage: 'Toggle token usage display on/off',
|
cmd_usage: 'Toggle token usage display on/off',
|
||||||
cmd_theme: 'Switch theme (dark/light/slate/solarized/monokai/nord/oled)',
|
cmd_theme: 'Switch theme (dark/light/slate/solarized/monokai/nord/oled)',
|
||||||
cmd_personality: 'Switch agent personality',
|
cmd_personality: 'Switch agent personality',
|
||||||
|
cmd_skills: 'List available Hermes skills',
|
||||||
available_commands: 'Available commands:',
|
available_commands: 'Available commands:',
|
||||||
type_slash: 'Type / to see commands',
|
type_slash: 'Type / to see commands',
|
||||||
conversation_cleared: 'Conversation cleared',
|
conversation_cleared: 'Conversation cleared',
|
||||||
@@ -136,6 +137,14 @@ const LOCALES = {
|
|||||||
login_btn: 'Sign in',
|
login_btn: 'Sign in',
|
||||||
login_invalid_pw: 'Invalid password',
|
login_invalid_pw: 'Invalid password',
|
||||||
login_conn_failed: 'Connection failed',
|
login_conn_failed: 'Connection failed',
|
||||||
|
dialog_confirm_title: 'Confirm action',
|
||||||
|
dialog_prompt_title: 'Enter a value',
|
||||||
|
dialog_confirm_btn: 'Confirm',
|
||||||
|
discard: 'Discard',
|
||||||
|
clear: 'Clear',
|
||||||
|
create: 'Create',
|
||||||
|
remove: 'Remove',
|
||||||
|
project_name_prompt: 'Project name:',
|
||||||
// Sidebar & Tabs
|
// Sidebar & Tabs
|
||||||
tab_chat: 'Chat',
|
tab_chat: 'Chat',
|
||||||
tab_tasks: 'Tasks',
|
tab_tasks: 'Tasks',
|
||||||
@@ -238,6 +247,7 @@ const LOCALES = {
|
|||||||
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
|
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
|
||||||
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)',
|
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)',
|
||||||
cmd_personality: 'Agenten-Persönlichkeit wechseln',
|
cmd_personality: 'Agenten-Persönlichkeit wechseln',
|
||||||
|
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
|
||||||
available_commands: 'Verfügbare Befehle:',
|
available_commands: 'Verfügbare Befehle:',
|
||||||
type_slash: 'Tippe / für Befehle',
|
type_slash: 'Tippe / für Befehle',
|
||||||
conversation_cleared: 'Konversation gelöscht',
|
conversation_cleared: 'Konversation gelöscht',
|
||||||
@@ -316,6 +326,14 @@ const LOCALES = {
|
|||||||
login_btn: 'Anmelden',
|
login_btn: 'Anmelden',
|
||||||
login_invalid_pw: 'Ungültiges Passwort',
|
login_invalid_pw: 'Ungültiges Passwort',
|
||||||
login_conn_failed: 'Verbindung fehlgeschlagen',
|
login_conn_failed: 'Verbindung fehlgeschlagen',
|
||||||
|
dialog_confirm_title: 'Aktion bestätigen',
|
||||||
|
dialog_prompt_title: 'Wert eingeben',
|
||||||
|
dialog_confirm_btn: 'Bestätigen',
|
||||||
|
discard: 'Verwerfen',
|
||||||
|
clear: 'Leeren',
|
||||||
|
create: 'Erstellen',
|
||||||
|
remove: 'Entfernen',
|
||||||
|
project_name_prompt: 'Projektname:',
|
||||||
// Sidebar & Tabs
|
// Sidebar & Tabs
|
||||||
tab_chat: 'Chat',
|
tab_chat: 'Chat',
|
||||||
tab_tasks: 'Aufgaben',
|
tab_tasks: 'Aufgaben',
|
||||||
@@ -418,6 +436,7 @@ const LOCALES = {
|
|||||||
cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a',
|
cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a',
|
||||||
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
|
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
|
||||||
cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe',
|
cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe',
|
||||||
|
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
|
||||||
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
||||||
type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
||||||
conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a',
|
conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a',
|
||||||
@@ -496,6 +515,14 @@ const LOCALES = {
|
|||||||
login_btn: '\u767b\u5f55',
|
login_btn: '\u767b\u5f55',
|
||||||
login_invalid_pw: '\u5bc6\u7801\u9519\u8bef',
|
login_invalid_pw: '\u5bc6\u7801\u9519\u8bef',
|
||||||
login_conn_failed: '\u8fde\u63a5\u5931\u8d25',
|
login_conn_failed: '\u8fde\u63a5\u5931\u8d25',
|
||||||
|
dialog_confirm_title: '确认操作',
|
||||||
|
dialog_prompt_title: '输入内容',
|
||||||
|
dialog_confirm_btn: '确认',
|
||||||
|
discard: '放弃',
|
||||||
|
clear: '清空',
|
||||||
|
create: '创建',
|
||||||
|
remove: '移除',
|
||||||
|
project_name_prompt: '项目名称:',
|
||||||
// missing keys from English
|
// missing keys from English
|
||||||
tab_chat: '\u804a\u5929',
|
tab_chat: '\u804a\u5929',
|
||||||
tab_memory: '\u8a18\u61b6',
|
tab_memory: '\u8a18\u61b6',
|
||||||
@@ -596,6 +623,7 @@ const LOCALES = {
|
|||||||
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
|
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
|
||||||
cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
|
cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
|
||||||
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
|
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
|
||||||
|
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
|
||||||
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
||||||
type_slash: '\u8f38\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
type_slash: '\u8f38\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
||||||
conversation_cleared: '\u5c0d\u8a71\u5df2\u6e05\u7a7a',
|
conversation_cleared: '\u5c0d\u8a71\u5df2\u6e05\u7a7a',
|
||||||
@@ -675,6 +703,14 @@ const LOCALES = {
|
|||||||
login_invalid_pw: '\u5bc6\u78bc\u932f\u8aa4',
|
login_invalid_pw: '\u5bc6\u78bc\u932f\u8aa4',
|
||||||
login_conn_failed: '\u9023\u63a5\u5931\u6557',
|
login_conn_failed: '\u9023\u63a5\u5931\u6557',
|
||||||
// missing keys from English
|
// missing keys from English
|
||||||
|
dialog_confirm_title: '確認操作',
|
||||||
|
dialog_prompt_title: '輸入內容',
|
||||||
|
dialog_confirm_btn: '確認',
|
||||||
|
discard: '放棄',
|
||||||
|
clear: '清空',
|
||||||
|
create: '建立',
|
||||||
|
remove: '移除',
|
||||||
|
project_name_prompt: '專案名稱:',
|
||||||
tab_chat: '\u804a\u5929',
|
tab_chat: '\u804a\u5929',
|
||||||
tab_memory: '\u8a18\u61b6',
|
tab_memory: '\u8a18\u61b6',
|
||||||
tab_skills: '\u6280\u80fd',
|
tab_skills: '\u6280\u80fd',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.46.0</div></div></div>
|
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.47.0</div></div></div>
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
||||||
@@ -328,6 +328,7 @@
|
|||||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></button>
|
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></button>
|
||||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
|
<button class="panel-icon-btn mobile-close-btn" onclick="closeMobileFiles()" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||||
@@ -438,7 +439,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
|
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar();closeMobileFiles()"></div>
|
||||||
<nav class="mobile-bottom-nav" id="mobileBottomNav">
|
<nav class="mobile-bottom-nav" id="mobileBottomNav">
|
||||||
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
|
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
@@ -471,5 +472,21 @@
|
|||||||
<script src="/static/messages.js"></script>
|
<script src="/static/messages.js"></script>
|
||||||
<script src="/static/panels.js"></script>
|
<script src="/static/panels.js"></script>
|
||||||
<script src="/static/boot.js"></script>
|
<script src="/static/boot.js"></script>
|
||||||
|
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
|
||||||
|
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
|
||||||
|
<div class="app-dialog-header">
|
||||||
|
<div class="app-dialog-title" id="appDialogTitle">Confirm action</div>
|
||||||
|
<button class="app-dialog-close" id="appDialogClose" type="button" aria-label="Close dialog">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="app-dialog-desc" id="appDialogDesc"></div>
|
||||||
|
<input class="app-dialog-input" id="appDialogInput" type="text" style="display:none">
|
||||||
|
<div class="app-dialog-actions">
|
||||||
|
<button class="app-dialog-btn" id="appDialogCancel" type="button" data-i18n="cancel">Cancel</button>
|
||||||
|
<button class="app-dialog-btn confirm" id="appDialogConfirm" type="button">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -296,7 +296,8 @@ async function cronEditSave(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cronDelete(id) {
|
async function cronDelete(id) {
|
||||||
if (!confirm('Delete this cron job? This cannot be undone.')) return;
|
const _delCron=await showConfirmDialog({title:'Delete cron job',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||||||
|
if(!_delCron) return;
|
||||||
try {
|
try {
|
||||||
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
|
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||||
showToast('Job deleted');
|
showToast('Job deleted');
|
||||||
@@ -339,7 +340,8 @@ function loadTodos() {
|
|||||||
|
|
||||||
async function clearConversation() {
|
async function clearConversation() {
|
||||||
if(!S.session) return;
|
if(!S.session) return;
|
||||||
if(!confirm('Clear all messages in this conversation? This cannot be undone.')) return;
|
const _clrMsg=await showConfirmDialog({title:'Clear conversation',message:'Clear all messages? This cannot be undone.',confirmLabel:'Clear',danger:true,focusCancel:true});
|
||||||
|
if(!_clrMsg) return;
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/session/clear', {method:'POST',
|
const data = await api('/api/session/clear', {method:'POST',
|
||||||
body: JSON.stringify({session_id: S.session.session_id})});
|
body: JSON.stringify({session_id: S.session.session_id})});
|
||||||
@@ -644,7 +646,8 @@ async function addWorkspace(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeWorkspace(path){
|
async function removeWorkspace(path){
|
||||||
if(!confirm(`Remove workspace "${path}"?`))return;
|
const _rmWs=await showConfirmDialog({title:'Remove workspace',message:`Remove "${path}"?`,confirmLabel:'Remove',danger:true,focusCancel:true});
|
||||||
|
if(!_rmWs) return;
|
||||||
try{
|
try{
|
||||||
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
|
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
|
||||||
_workspaceList=data.workspaces;
|
_workspaceList=data.workspaces;
|
||||||
@@ -872,7 +875,8 @@ async function submitProfileCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteProfile(name) {
|
async function deleteProfile(name) {
|
||||||
if (!confirm(`Delete profile "${name}"? This removes all config, skills, memory, and sessions for this profile.`)) return;
|
const _delProf=await showConfirmDialog({title:`Delete profile "${name}"?`,message:'This removes all config, skills, memory, and sessions for this profile.',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||||||
|
if(!_delProf) return;
|
||||||
try {
|
try {
|
||||||
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
||||||
await loadProfilesPanel();
|
await loadProfilesPanel();
|
||||||
@@ -1131,7 +1135,8 @@ async function signOut(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function disableAuth(){
|
async function disableAuth(){
|
||||||
if(!confirm('Disable password protection? Anyone will be able to access this instance.')) return;
|
const _disAuth=await showConfirmDialog({title:'Disable password protection',message:'Anyone will be able to access this instance.',confirmLabel:'Disable',danger:true,focusCancel:true});
|
||||||
|
if(!_disAuth) return;
|
||||||
try{
|
try{
|
||||||
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
|
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
|
||||||
showToast('Auth disabled — password protection removed');
|
showToast('Auth disabled — password protection removed');
|
||||||
|
|||||||
@@ -7,8 +7,169 @@ const ICONS={
|
|||||||
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
|
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
|
||||||
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
|
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
|
||||||
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
|
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
|
||||||
|
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let _sessionActionMenu = null;
|
||||||
|
let _sessionActionAnchor = null;
|
||||||
|
let _sessionActionSessionId = null;
|
||||||
|
|
||||||
|
function closeSessionActionMenu(){
|
||||||
|
if(_sessionActionMenu){
|
||||||
|
_sessionActionMenu.remove();
|
||||||
|
_sessionActionMenu = null;
|
||||||
|
}
|
||||||
|
if(_sessionActionAnchor){
|
||||||
|
_sessionActionAnchor.classList.remove('active');
|
||||||
|
const row=_sessionActionAnchor.closest('.session-item');
|
||||||
|
if(row) row.classList.remove('menu-open');
|
||||||
|
_sessionActionAnchor = null;
|
||||||
|
}
|
||||||
|
_sessionActionSessionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _positionSessionActionMenu(anchorEl){
|
||||||
|
if(!_sessionActionMenu || !anchorEl) return;
|
||||||
|
const rect=anchorEl.getBoundingClientRect();
|
||||||
|
const menuW=Math.min(280, Math.max(220, _sessionActionMenu.scrollWidth || 220));
|
||||||
|
let left=rect.right-menuW;
|
||||||
|
if(left<8) left=8;
|
||||||
|
if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8;
|
||||||
|
_sessionActionMenu.style.left=left+'px';
|
||||||
|
_sessionActionMenu.style.top='8px';
|
||||||
|
const menuH=_sessionActionMenu.offsetHeight || 0;
|
||||||
|
let top=rect.bottom+6;
|
||||||
|
if(top+menuH>window.innerHeight-8 && rect.top>menuH+12){
|
||||||
|
top=rect.top-menuH-6;
|
||||||
|
}
|
||||||
|
if(top<8) top=8;
|
||||||
|
_sessionActionMenu.style.top=top+'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
|
||||||
|
const opt=document.createElement('button');
|
||||||
|
opt.type='button';
|
||||||
|
opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
|
||||||
|
opt.innerHTML=
|
||||||
|
`<span class="ws-opt-action">`
|
||||||
|
+ `<span class="ws-opt-icon">${icon}</span>`
|
||||||
|
+ `<span class="session-action-copy">`
|
||||||
|
+ `<span class="ws-opt-name">${esc(label)}</span>`
|
||||||
|
+ (meta?`<span class="session-action-meta">${esc(meta)}</span>`:'')
|
||||||
|
+ `</span>`
|
||||||
|
+ `</span>`;
|
||||||
|
opt.onclick=async(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
await onSelect();
|
||||||
|
};
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openSessionActionMenu(session, anchorEl){
|
||||||
|
if(_sessionActionMenu && _sessionActionSessionId===session.session_id && _sessionActionAnchor===anchorEl){
|
||||||
|
closeSessionActionMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeSessionActionMenu();
|
||||||
|
const menu=document.createElement('div');
|
||||||
|
menu.className='session-action-menu open';
|
||||||
|
menu.appendChild(_buildSessionAction(
|
||||||
|
session.pinned?'Unpin conversation':'Pin conversation',
|
||||||
|
session.pinned?'Remove from the pinned section':'Keep this conversation at the top',
|
||||||
|
session.pinned?ICONS.pin:ICONS.unpin,
|
||||||
|
async()=>{
|
||||||
|
closeSessionActionMenu();
|
||||||
|
const newPinned=!session.pinned;
|
||||||
|
try{
|
||||||
|
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
|
||||||
|
session.pinned=newPinned;
|
||||||
|
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
|
||||||
|
renderSessionList();
|
||||||
|
}catch(err){showToast('Pin failed: '+err.message);}
|
||||||
|
},
|
||||||
|
session.pinned?'is-active':''
|
||||||
|
));
|
||||||
|
menu.appendChild(_buildSessionAction(
|
||||||
|
'Move to project',
|
||||||
|
session.project_id?'Change which project this conversation belongs to':'Assign this conversation to a project',
|
||||||
|
ICONS.folder,
|
||||||
|
async()=>{
|
||||||
|
closeSessionActionMenu();
|
||||||
|
_showProjectPicker(session, anchorEl);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
menu.appendChild(_buildSessionAction(
|
||||||
|
session.archived?'Restore conversation':'Archive conversation',
|
||||||
|
session.archived?'Bring this conversation back into the main list':'Hide this conversation until archived is shown',
|
||||||
|
session.archived?ICONS.unarchive:ICONS.archive,
|
||||||
|
async()=>{
|
||||||
|
closeSessionActionMenu();
|
||||||
|
try{
|
||||||
|
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
|
||||||
|
session.archived=!session.archived;
|
||||||
|
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
|
||||||
|
await renderSessionList();
|
||||||
|
showToast(session.archived?'Session archived':'Session restored');
|
||||||
|
}catch(err){showToast('Archive failed: '+err.message);}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
menu.appendChild(_buildSessionAction(
|
||||||
|
'Duplicate conversation',
|
||||||
|
'Create a copy with the same workspace and model',
|
||||||
|
ICONS.dup,
|
||||||
|
async()=>{
|
||||||
|
closeSessionActionMenu();
|
||||||
|
try{
|
||||||
|
const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:session.workspace,model:session.model})});
|
||||||
|
if(res.session){
|
||||||
|
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(session.title||'Untitled')+' (copy)'})});
|
||||||
|
await loadSession(res.session.session_id);
|
||||||
|
await renderSessionList();
|
||||||
|
showToast('Session duplicated');
|
||||||
|
}
|
||||||
|
}catch(err){showToast('Duplicate failed: '+err.message);}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
menu.appendChild(_buildSessionAction(
|
||||||
|
'Delete conversation',
|
||||||
|
'Permanently remove this conversation',
|
||||||
|
ICONS.trash,
|
||||||
|
async()=>{
|
||||||
|
closeSessionActionMenu();
|
||||||
|
await deleteSession(session.session_id);
|
||||||
|
},
|
||||||
|
'danger'
|
||||||
|
));
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
_sessionActionMenu = menu;
|
||||||
|
_sessionActionAnchor = anchorEl;
|
||||||
|
_sessionActionSessionId = session.session_id;
|
||||||
|
anchorEl.classList.add('active');
|
||||||
|
const row=anchorEl.closest('.session-item');
|
||||||
|
if(row) row.classList.add('menu-open');
|
||||||
|
_positionSessionActionMenu(anchorEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click',e=>{
|
||||||
|
if(!_sessionActionMenu) return;
|
||||||
|
if(_sessionActionMenu.contains(e.target)) return;
|
||||||
|
if(_sessionActionAnchor && _sessionActionAnchor.contains(e.target)) return;
|
||||||
|
closeSessionActionMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('scroll',e=>{
|
||||||
|
if(!_sessionActionMenu) return;
|
||||||
|
if(_sessionActionMenu.contains(e.target)) return;
|
||||||
|
closeSessionActionMenu();
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('keydown',e=>{
|
||||||
|
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu();
|
||||||
|
});
|
||||||
|
window.addEventListener('resize',()=>{
|
||||||
|
if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
|
||||||
|
});
|
||||||
|
|
||||||
async function newSession(flash){
|
async function newSession(flash){
|
||||||
MSG_QUEUE.length=0;updateQueueBadge();
|
MSG_QUEUE.length=0;updateQueueBadge();
|
||||||
S.toolCalls=[];
|
S.toolCalls=[];
|
||||||
@@ -314,7 +475,7 @@ function renderSessionListFromCache(){
|
|||||||
if(s.project_id){
|
if(s.project_id){
|
||||||
const proj=_allProjects.find(p=>p.project_id===s.project_id);
|
const proj=_allProjects.find(p=>p.project_id===s.project_id);
|
||||||
if(proj){
|
if(proj){
|
||||||
if(!isActive) el.style.borderLeftColor=proj.color||'var(--blue)';
|
// project color shown via dot indicator, not left border
|
||||||
const dot=document.createElement('span');
|
const dot=document.createElement('span');
|
||||||
dot.className='session-project-dot';
|
dot.className='session-project-dot';
|
||||||
dot.style.background=proj.color||'var(--blue)';
|
dot.style.background=proj.color||'var(--blue)';
|
||||||
@@ -323,65 +484,21 @@ function renderSessionListFromCache(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.appendChild(title);
|
el.appendChild(title);
|
||||||
// Action buttons overlay (appears on hover with gradient fade)
|
|
||||||
const actions=document.createElement('div');
|
const actions=document.createElement('div');
|
||||||
actions.className='session-actions';
|
actions.className='session-actions';
|
||||||
// Pin toggle
|
const menuBtn=document.createElement('button');
|
||||||
const pinBtn=document.createElement('button');
|
menuBtn.type='button';
|
||||||
pinBtn.className='act-pin'+(s.pinned?' pinned':'');
|
menuBtn.className='session-actions-trigger';
|
||||||
pinBtn.innerHTML=s.pinned?ICONS.pin:ICONS.unpin;
|
menuBtn.title='Conversation actions';
|
||||||
pinBtn.title=s.pinned?'Unpin':'Pin to top';
|
menuBtn.setAttribute('aria-haspopup','menu');
|
||||||
pinBtn.onclick=async(e)=>{
|
menuBtn.setAttribute('aria-label','Conversation actions');
|
||||||
e.stopPropagation();e.preventDefault();
|
menuBtn.innerHTML=ICONS.more;
|
||||||
const newPinned=!s.pinned;
|
menuBtn.onclick=(e)=>{
|
||||||
try{
|
e.stopPropagation();
|
||||||
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:s.session_id,pinned:newPinned})});
|
e.preventDefault();
|
||||||
s.pinned=newPinned;
|
_openSessionActionMenu(s, menuBtn);
|
||||||
if(S.session&&S.session.session_id===s.session_id) S.session.pinned=newPinned;
|
|
||||||
renderSessionList();
|
|
||||||
}catch(err){showToast('Pin failed: '+err.message);}
|
|
||||||
};
|
};
|
||||||
actions.appendChild(pinBtn);
|
actions.appendChild(menuBtn);
|
||||||
// Move to project
|
|
||||||
const move=document.createElement('button');
|
|
||||||
move.className='act-move';move.innerHTML=ICONS.folder;move.title='Move to project';
|
|
||||||
move.onclick=async(e)=>{e.stopPropagation();e.preventDefault();_showProjectPicker(s,move);};
|
|
||||||
actions.appendChild(move);
|
|
||||||
// Archive
|
|
||||||
const archive=document.createElement('button');
|
|
||||||
archive.className='act-archive';archive.innerHTML=s.archived?ICONS.unarchive:ICONS.archive;
|
|
||||||
archive.title=s.archived?'Unarchive':'Archive';
|
|
||||||
archive.onclick=async(e)=>{
|
|
||||||
e.stopPropagation();e.preventDefault();
|
|
||||||
try{
|
|
||||||
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:s.session_id,archived:!s.archived})});
|
|
||||||
s.archived=!s.archived;
|
|
||||||
if(S.session&&S.session.session_id===s.session_id) S.session.archived=s.archived;
|
|
||||||
await renderSessionList();
|
|
||||||
showToast(s.archived?'Session archived':'Session restored');
|
|
||||||
}catch(err){showToast('Archive failed: '+err.message);}
|
|
||||||
};
|
|
||||||
actions.appendChild(archive);
|
|
||||||
// Duplicate
|
|
||||||
const dup=document.createElement('button');
|
|
||||||
dup.className='act-dup';dup.innerHTML=ICONS.dup;dup.title='Duplicate';
|
|
||||||
dup.onclick=async(e)=>{
|
|
||||||
e.stopPropagation();e.preventDefault();
|
|
||||||
try{
|
|
||||||
const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:s.workspace,model:s.model})});
|
|
||||||
if(res.session){
|
|
||||||
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(s.title||'Untitled')+' (copy)'})});
|
|
||||||
await loadSession(res.session.session_id);await renderSessionList();
|
|
||||||
showToast('Session duplicated');
|
|
||||||
}
|
|
||||||
}catch(err){showToast('Duplicate failed: '+err.message);}
|
|
||||||
};
|
|
||||||
actions.appendChild(dup);
|
|
||||||
// Trash
|
|
||||||
const trash=document.createElement('button');
|
|
||||||
trash.className='act-trash';trash.innerHTML=ICONS.trash;trash.title='Delete';
|
|
||||||
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
|
|
||||||
actions.appendChild(trash);
|
|
||||||
el.appendChild(actions);
|
el.appendChild(actions);
|
||||||
|
|
||||||
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
|
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
|
||||||
@@ -417,7 +534,8 @@ function renderSessionListFromCache(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSession(sid){
|
async function deleteSession(sid){
|
||||||
if(!confirm('Delete this conversation?'))return;
|
const _delSess=await showConfirmDialog({title:'Delete conversation',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||||||
|
if(!_delSess) return;
|
||||||
try{
|
try{
|
||||||
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||||
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
|
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
|
||||||
@@ -493,7 +611,7 @@ function _showProjectPicker(session, anchorEl){
|
|||||||
picker.remove();
|
picker.remove();
|
||||||
document.removeEventListener('click',close);
|
document.removeEventListener('click',close);
|
||||||
// Prompt for name inline
|
// Prompt for name inline
|
||||||
const name=prompt('Project name:');
|
const name=await showPromptDialog({title:'New project',message:'',placeholder:'Project name',confirmLabel:t('create')});
|
||||||
if(!name||!name.trim()) return;
|
if(!name||!name.trim()) return;
|
||||||
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
||||||
const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
|
const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
|
||||||
@@ -532,7 +650,7 @@ function _showProjectPicker(session, anchorEl){
|
|||||||
setTimeout(()=>document.addEventListener('click',close),0);
|
setTimeout(()=>document.addEventListener('click',close),0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _startProjectCreate(bar, addBtn){
|
async function _startProjectCreate(bar, addBtn){
|
||||||
const inp=document.createElement('input');
|
const inp=document.createElement('input');
|
||||||
inp.className='project-create-input';
|
inp.className='project-create-input';
|
||||||
inp.placeholder='Project name';
|
inp.placeholder='Project name';
|
||||||
@@ -579,7 +697,8 @@ function _startProjectRename(proj, chip){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _confirmDeleteProject(proj){
|
async function _confirmDeleteProject(proj){
|
||||||
if(!confirm('Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.')){return;}
|
const _delProj=await showConfirmDialog({title:`Delete project "${proj.name}"?`,message:'Sessions will be unassigned but not deleted.',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||||||
|
if(!_delProj) return;
|
||||||
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
|
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
|
||||||
if(_activeProject===proj.project_id) _activeProject=null;
|
if(_activeProject===proj.project_id) _activeProject=null;
|
||||||
await renderSessionList();
|
await renderSessionList();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
/* ── Light theme: sidebar, roles, chips, active states ── */
|
/* ── Light theme: sidebar, roles, chips, active states ── */
|
||||||
:root[data-theme="light"] .session-item{color:#5a544a;}
|
:root[data-theme="light"] .session-item{color:#5a544a;}
|
||||||
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
|
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
|
||||||
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;border-left-color:#2d6fa3;}
|
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;}
|
||||||
:root[data-theme="light"] .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(228,224,216,.95) 12px);}
|
:root[data-theme="light"] .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(228,224,216,.95) 12px);}
|
||||||
:root[data-theme="light"] .session-pin-indicator{color:#996b15;}
|
:root[data-theme="light"] .session-pin-indicator{color:#996b15;}
|
||||||
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
|
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
|
||||||
@@ -129,15 +129,24 @@
|
|||||||
.session-item:hover{background:var(--hover-bg);color:var(--text);}
|
.session-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||||
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;border-left:2px solid #e8a030;padding-left:8px;}
|
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;border-left:2px solid #e8a030;padding-left:8px;}
|
||||||
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
/* ── Session action button overlay ── */
|
/* ── Session action trigger + dropdown (⋯ menu) ── */
|
||||||
.session-actions{position:absolute;right:0;top:0;bottom:0;display:flex;align-items:center;gap:2px;padding:0 6px 0 16px;background:linear-gradient(to right,transparent,var(--sidebar) 12px);opacity:0;pointer-events:none;transition:opacity .15s ease;border-radius:0 8px 8px 0;}
|
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
|
||||||
.session-item:hover .session-actions{opacity:1;pointer-events:auto;}
|
.session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}
|
||||||
.session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(30,22,8,.95) 12px);}
|
.session-actions-trigger{width:26px;height:26px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer;padding:0;line-height:1;display:inline-flex;align-items:center;justify-content:center;transition:background .12s,color .12s,border-color .12s;}
|
||||||
.session-actions button{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px 3px;line-height:1;transition:color .12s;display:flex;align-items:center;}
|
.session-actions-trigger:hover{background:var(--hover-bg);color:var(--text);}
|
||||||
.session-actions button:hover{color:var(--text);}
|
.session-actions-trigger.active{background:rgba(124,185,255,.1);border-color:rgba(124,185,255,.2);color:var(--text);}
|
||||||
.session-actions .act-trash:hover{color:var(--accent);}
|
.session-actions-trigger svg{display:block;}
|
||||||
.session-actions .act-pin.pinned{color:#f5c542;}
|
.session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||||
.session-actions .act-pin.pinned:hover{color:#d4a017;}
|
.session-action-menu.open{display:block;}
|
||||||
|
.session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);}
|
||||||
|
.session-action-opt .ws-opt-action{width:100%;align-items:flex-start;}
|
||||||
|
.session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;}
|
||||||
|
.session-action-opt:hover .ws-opt-icon{color:var(--text);opacity:1;}
|
||||||
|
.session-action-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
||||||
|
.session-action-meta{font-size:11px;color:var(--muted);line-height:1.3;white-space:normal;opacity:.72;}
|
||||||
|
.session-action-opt.is-active{background:rgba(124,185,255,.1);}
|
||||||
|
.session-action-opt.danger:hover{background:rgba(233,69,96,.08);}
|
||||||
|
.session-action-opt.danger .ws-opt-icon,.session-action-opt.danger .ws-opt-name{color:var(--accent);}
|
||||||
/* Hide overlay during inline rename */
|
/* Hide overlay during inline rename */
|
||||||
.session-item:has(.session-title-input) .session-actions{display:none;}
|
.session-item:has(.session-title-input) .session-actions{display:none;}
|
||||||
@keyframes newflash{0%{background:rgba(124,185,255,0.22);color:var(--blue);}100%{background:transparent;color:var(--muted);}}
|
@keyframes newflash{0%{background:rgba(124,185,255,0.22);color:var(--blue);}100%{background:transparent;color:var(--muted);}}
|
||||||
@@ -148,6 +157,25 @@
|
|||||||
.session-date-header.pinned{color:#f5c542;}
|
.session-date-header.pinned{color:#f5c542;}
|
||||||
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
|
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
|
||||||
.session-date-caret.collapsed{transform:rotate(-90deg);}
|
.session-date-caret.collapsed{transform:rotate(-90deg);}
|
||||||
|
|
||||||
|
/* ── Shared app dialogs (replace native confirm/prompt) ── */
|
||||||
|
.app-dialog-overlay{position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);z-index:1100;display:none;align-items:center;justify-content:center;padding:24px;}
|
||||||
|
.app-dialog{width:min(460px,100%);background:linear-gradient(180deg,rgba(21,31,45,.98),rgba(13,20,31,.98));border:1px solid rgba(124,185,255,.2);border-radius:18px;box-shadow:0 18px 60px rgba(0,0,0,.45);padding:18px 18px 16px;color:var(--text);}
|
||||||
|
.app-dialog-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;}
|
||||||
|
.app-dialog-title{font-size:16px;font-weight:700;letter-spacing:.01em;color:var(--text);}
|
||||||
|
.app-dialog-close{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:none;border-radius:10px;background:rgba(255,255,255,.04);color:var(--muted);cursor:pointer;transition:background .15s,color .15s;}
|
||||||
|
.app-dialog-close:hover{background:rgba(255,255,255,.09);color:var(--text);}
|
||||||
|
.app-dialog-desc{font-size:13px;line-height:1.6;color:var(--muted);white-space:pre-wrap;}
|
||||||
|
.app-dialog-input{width:100%;margin-top:14px;padding:11px 12px;background:rgba(255,255,255,.04);border:1px solid var(--border2);border-radius:10px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;}
|
||||||
|
.app-dialog-input:focus{border-color:rgba(124,185,255,.55);box-shadow:0 0 0 3px rgba(124,185,255,.12);}
|
||||||
|
.app-dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:18px;flex-wrap:wrap;}
|
||||||
|
.app-dialog-btn{display:inline-flex;align-items:center;justify-content:center;min-width:104px;padding:10px 14px;border-radius:10px;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:transform .15s,background .15s,border-color .15s;}
|
||||||
|
.app-dialog-btn:hover{transform:translateY(-1px);background:rgba(255,255,255,.1);}
|
||||||
|
.app-dialog-btn.confirm{border-color:rgba(124,185,255,.45);background:rgba(124,185,255,.14);color:var(--blue);}
|
||||||
|
.app-dialog-btn.confirm:hover{background:rgba(124,185,255,.22);border-color:rgba(124,185,255,.65);}
|
||||||
|
.app-dialog-btn.confirm.danger{border-color:rgba(233,69,96,.4);background:rgba(233,69,96,.14);color:var(--accent);}
|
||||||
|
.app-dialog-btn.confirm.danger:hover{background:rgba(233,69,96,.22);border-color:rgba(233,69,96,.58);}
|
||||||
|
.app-dialog-btn:focus-visible,.app-dialog-close:focus-visible{outline:2px solid rgba(124,185,255,.85);outline-offset:2px;}
|
||||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--surface);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--surface);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
||||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
|
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
|
||||||
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||||
@@ -342,6 +370,7 @@
|
|||||||
.panel-actions{display:flex;gap:4px;}
|
.panel-actions{display:flex;gap:4px;}
|
||||||
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||||
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||||
|
.mobile-close-btn{display:none;}
|
||||||
/* File row actions (shown on hover) */
|
/* File row actions (shown on hover) */
|
||||||
/* file-item-actions removed: delete button is now a flex child */
|
/* file-item-actions removed: delete button is now a flex child */
|
||||||
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
|
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
|
||||||
@@ -471,12 +500,20 @@
|
|||||||
.approval-btns{gap:6px;}
|
.approval-btns{gap:6px;}
|
||||||
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
|
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
|
||||||
.approval-kbd{display:none;}
|
.approval-kbd{display:none;}
|
||||||
|
.app-dialog-overlay{padding:12px;}
|
||||||
|
.app-dialog{width:100%;padding:16px 16px 14px;border-radius:16px;}
|
||||||
|
.app-dialog-actions{flex-direction:column-reverse;align-items:stretch;}
|
||||||
|
.app-dialog-btn{width:100%;min-height:44px;}
|
||||||
/* Tool cards */
|
/* Tool cards */
|
||||||
.tool-card{margin-left:0!important;font-size:12px;}
|
.tool-card{margin-left:0!important;font-size:12px;}
|
||||||
/* Settings modal */
|
/* Settings modal */
|
||||||
.settings-panel{width:95vw;max-width:95vw;min-height:min(580px,88vh);max-height:92vh;}
|
.settings-panel{width:95vw;max-width:95vw;min-height:min(580px,88vh);max-height:92vh;}
|
||||||
/* Login page responsive */
|
/* Login page responsive */
|
||||||
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
||||||
|
/* Workspace panel mobile close button */
|
||||||
|
.mobile-close-btn{display:inline-flex;}
|
||||||
|
/* Profile dropdown — escape overflow-x:auto clipping context */
|
||||||
|
.profile-dropdown{position:fixed;top:56px;right:8px;left:auto;max-width:calc(100vw - 16px);}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Workspace dropdown (topbar) ── */
|
/* ── Workspace dropdown (topbar) ── */
|
||||||
@@ -828,7 +865,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
|||||||
/* ── CLI session items in sidebar ── */
|
/* ── CLI session items in sidebar ── */
|
||||||
.session-item.cli-session {
|
.session-item.cli-session {
|
||||||
border-left-color: var(--gold);
|
border-left-color: var(--gold);
|
||||||
padding-right: 36px; /* make room for session-actions overlay */
|
padding-right: 40px; /* make room for the session actions trigger */
|
||||||
}
|
}
|
||||||
.session-item.cli-session::after {
|
.session-item.cli-session::after {
|
||||||
content: 'cli';
|
content: 'cli';
|
||||||
|
|||||||
149
static/ui.js
149
static/ui.js
@@ -296,6 +296,148 @@ function updateQueueBadge(){
|
|||||||
}
|
}
|
||||||
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
|
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
|
||||||
|
|
||||||
|
// ── Shared app dialogs ───────────────────────────────────────────────────────
|
||||||
|
// showConfirmDialog(opts) and showPromptDialog(opts) replace browser-native dialog calls
|
||||||
|
// throughout the UI. Both return Promises and support: title, message, confirmLabel,
|
||||||
|
// cancelLabel, danger (confirm only), placeholder/value/inputType (prompt only).
|
||||||
|
|
||||||
|
const APP_DIALOG={resolve:null,kind:null,lastFocus:null};
|
||||||
|
let _appDialogBound=false;
|
||||||
|
|
||||||
|
function _isAppDialogOpen(){
|
||||||
|
const overlay=$('appDialogOverlay');
|
||||||
|
return !!(overlay&&overlay.style.display!=='none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getAppDialogFocusable(){
|
||||||
|
return [$('appDialogInput'), $('appDialogCancel'), $('appDialogConfirm'), $('appDialogClose')]
|
||||||
|
.filter(el=>el&&el.style.display!=='none'&&!el.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _finishAppDialog(result, restoreFocus=true){
|
||||||
|
const overlay=$('appDialogOverlay');
|
||||||
|
const dialog=$('appDialog');
|
||||||
|
const input=$('appDialogInput');
|
||||||
|
const confirmBtn=$('appDialogConfirm');
|
||||||
|
const resolve=APP_DIALOG.resolve;
|
||||||
|
const lastFocus=APP_DIALOG.lastFocus;
|
||||||
|
APP_DIALOG.resolve=null;
|
||||||
|
APP_DIALOG.kind=null;
|
||||||
|
APP_DIALOG.lastFocus=null;
|
||||||
|
if(overlay){overlay.style.display='none';overlay.setAttribute('aria-hidden','true');}
|
||||||
|
if(dialog) dialog.setAttribute('role','dialog');
|
||||||
|
if(input){input.value='';input.style.display='none';input.placeholder='';}
|
||||||
|
if(confirmBtn){confirmBtn.classList.remove('danger');confirmBtn.textContent=t('dialog_confirm_btn');}
|
||||||
|
if(restoreFocus&&lastFocus&&typeof lastFocus.focus==='function'){setTimeout(()=>lastFocus.focus(),0);}
|
||||||
|
if(resolve) resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureAppDialogBindings(){
|
||||||
|
if(_appDialogBound) return;
|
||||||
|
_appDialogBound=true;
|
||||||
|
const overlay=$('appDialogOverlay');
|
||||||
|
const cancelBtn=$('appDialogCancel');
|
||||||
|
const confirmBtn=$('appDialogConfirm');
|
||||||
|
const closeBtn=$('appDialogClose');
|
||||||
|
if(overlay){
|
||||||
|
overlay.addEventListener('click',e=>{
|
||||||
|
if(e.target===overlay) _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(cancelBtn) cancelBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false));
|
||||||
|
if(closeBtn) closeBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false));
|
||||||
|
if(confirmBtn){
|
||||||
|
confirmBtn.addEventListener('click',()=>{
|
||||||
|
if(APP_DIALOG.kind==='prompt'){
|
||||||
|
const input=$('appDialogInput');
|
||||||
|
_finishAppDialog(input?input.value:null);
|
||||||
|
}else{
|
||||||
|
_finishAppDialog(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown',e=>{
|
||||||
|
if(!_isAppDialogOpen()) return;
|
||||||
|
if(e.key==='Escape'){
|
||||||
|
e.preventDefault();
|
||||||
|
_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(e.key==='Enter'){
|
||||||
|
const target=e.target;
|
||||||
|
const isTextarea=target&&target.tagName==='TEXTAREA';
|
||||||
|
if(!isTextarea){
|
||||||
|
e.preventDefault();
|
||||||
|
if(target===cancelBtn||target===closeBtn){
|
||||||
|
_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false);
|
||||||
|
}else if(APP_DIALOG.kind==='prompt'){
|
||||||
|
const input=$('appDialogInput');
|
||||||
|
_finishAppDialog(input?input.value:null);
|
||||||
|
}else{
|
||||||
|
_finishAppDialog(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(e.key==='Tab'){
|
||||||
|
const nodes=_getAppDialogFocusable();
|
||||||
|
if(!nodes.length) return;
|
||||||
|
const idx=nodes.indexOf(document.activeElement);
|
||||||
|
let nextIdx=idx;
|
||||||
|
if(e.shiftKey){nextIdx=idx<=0?nodes.length-1:idx-1;}
|
||||||
|
else{nextIdx=idx===-1||idx===nodes.length-1?0:idx+1;}
|
||||||
|
e.preventDefault();
|
||||||
|
nodes[nextIdx].focus();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfirmDialog(opts={}){
|
||||||
|
_ensureAppDialogBindings();
|
||||||
|
if(APP_DIALOG.resolve) _finishAppDialog(false,false);
|
||||||
|
const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'),
|
||||||
|
desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm');
|
||||||
|
APP_DIALOG.resolve=null;APP_DIALOG.kind='confirm';APP_DIALOG.lastFocus=document.activeElement;
|
||||||
|
if(title) title.textContent=opts.title||t('dialog_confirm_title');
|
||||||
|
if(desc) desc.textContent=opts.message||'';
|
||||||
|
if(input){input.style.display='none';input.value='';}
|
||||||
|
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
|
||||||
|
if(confirmBtn){
|
||||||
|
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
|
||||||
|
confirmBtn.classList.toggle('danger',!!opts.danger);
|
||||||
|
}
|
||||||
|
if(dialog) dialog.setAttribute('role',opts.danger?'alertdialog':'dialog');
|
||||||
|
if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');}
|
||||||
|
return new Promise(resolve=>{
|
||||||
|
APP_DIALOG.resolve=resolve;
|
||||||
|
setTimeout(()=>((opts.focusCancel?cancelBtn:confirmBtn)||confirmBtn||cancelBtn).focus(),0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPromptDialog(opts={}){
|
||||||
|
_ensureAppDialogBindings();
|
||||||
|
if(APP_DIALOG.resolve) _finishAppDialog(null,false);
|
||||||
|
const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'),
|
||||||
|
desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm');
|
||||||
|
APP_DIALOG.resolve=null;APP_DIALOG.kind='prompt';APP_DIALOG.lastFocus=document.activeElement;
|
||||||
|
if(title) title.textContent=opts.title||t('dialog_prompt_title');
|
||||||
|
if(desc) desc.textContent=opts.message||'';
|
||||||
|
if(input){
|
||||||
|
input.type=opts.inputType||'text';input.style.display='';
|
||||||
|
input.value=opts.value||'';input.placeholder=opts.placeholder||'';
|
||||||
|
input.autocomplete='off';input.spellcheck=false;
|
||||||
|
}
|
||||||
|
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
|
||||||
|
if(confirmBtn){confirmBtn.textContent=opts.confirmLabel||t('create');confirmBtn.classList.remove('danger');}
|
||||||
|
if(dialog) dialog.setAttribute('role','dialog');
|
||||||
|
if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');}
|
||||||
|
return new Promise(resolve=>{
|
||||||
|
APP_DIALOG.resolve=resolve;
|
||||||
|
setTimeout(()=>{if(input&&input.style.display!=='none')input.focus();else if(confirmBtn)confirmBtn.focus();},0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function copyMsg(btn){
|
function copyMsg(btn){
|
||||||
const row=btn.closest('.msg-row');
|
const row=btn.closest('.msg-row');
|
||||||
const text=row?row.dataset.rawText:'';
|
const text=row?row.dataset.rawText:'';
|
||||||
@@ -1125,7 +1267,8 @@ function _renderTreeItems(container, entries, depth){
|
|||||||
|
|
||||||
async function deleteWorkspaceFile(relPath, name){
|
async function deleteWorkspaceFile(relPath, name){
|
||||||
if(!S.session)return;
|
if(!S.session)return;
|
||||||
if(!confirm(t('delete_confirm',name)))return;
|
const _delFile=await showConfirmDialog({title:t('delete_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||||||
|
if(!_delFile) return;
|
||||||
try{
|
try{
|
||||||
await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
|
await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
|
||||||
showToast(t('deleted')+name);
|
showToast(t('deleted')+name);
|
||||||
@@ -1137,7 +1280,7 @@ async function deleteWorkspaceFile(relPath, name){
|
|||||||
|
|
||||||
async function promptNewFile(){
|
async function promptNewFile(){
|
||||||
if(!S.session)return;
|
if(!S.session)return;
|
||||||
const name=prompt(t('new_file_prompt'),'');
|
const name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')});
|
||||||
if(!name||!name.trim())return;
|
if(!name||!name.trim())return;
|
||||||
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
|
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
|
||||||
try{
|
try{
|
||||||
@@ -1150,7 +1293,7 @@ async function promptNewFile(){
|
|||||||
|
|
||||||
async function promptNewFolder(){
|
async function promptNewFolder(){
|
||||||
if(!S.session)return;
|
if(!S.session)return;
|
||||||
const name=prompt(t('new_folder_prompt'),'');
|
const name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')});
|
||||||
if(!name||!name.trim())return;
|
if(!name||!name.trim())return;
|
||||||
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
|
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async function loadDir(path){
|
|||||||
}
|
}
|
||||||
if(typeof clearPreview==='function'){
|
if(typeof clearPreview==='function'){
|
||||||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
||||||
if(confirm(t('unsaved_confirm')))clearPreview();
|
showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();});
|
||||||
}else{
|
}else{
|
||||||
clearPreview();
|
clearPreview();
|
||||||
}
|
}
|
||||||
|
|||||||
177
tests/test_mobile_layout.py
Normal file
177
tests/test_mobile_layout.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
Mobile layout regression tests — run on every QA pass.
|
||||||
|
|
||||||
|
These tests check that the CSS and HTML structure required for correct
|
||||||
|
mobile rendering (375px–640px viewport widths) is intact after every change.
|
||||||
|
They are static checks (no server needed) that catch common regressions:
|
||||||
|
|
||||||
|
- Mobile breakpoints present for key layout elements
|
||||||
|
- Right panel slide-over markup and CSS intact
|
||||||
|
- Profile dropdown not clipped by overflow on mobile
|
||||||
|
- Composer footer chips scroll correctly on narrow viewports
|
||||||
|
- Mobile bottom nav and overlay markup present
|
||||||
|
- No full-viewport overflow that would break scroll
|
||||||
|
|
||||||
|
Run as part of the standard test suite:
|
||||||
|
pytest tests/test_mobile_layout.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
REPO = pathlib.Path(__file__).parent.parent
|
||||||
|
HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||||
|
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mobile breakpoint rules ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_mobile_breakpoint_900px_present():
|
||||||
|
"""@media(max-width:900px) must hide the right panel and show mobile-files-btn."""
|
||||||
|
assert "@media(max-width:900px)" in CSS or "@media (max-width: 900px)" in CSS, \
|
||||||
|
"Missing @media(max-width:900px) breakpoint in style.css"
|
||||||
|
# Right panel should be hidden at 900px, replaced by slide-over
|
||||||
|
assert ".rightpanel{display:none" in CSS or ".rightpanel {display:none" in CSS or \
|
||||||
|
re.search(r'max-width:900px\).*?\.rightpanel\{display:none', CSS, re.DOTALL), \
|
||||||
|
".rightpanel must be display:none at max-width:900px (slide-over replaces it)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_breakpoint_640px_present():
|
||||||
|
"""@media(max-width:640px) must exist for narrow phone layouts."""
|
||||||
|
assert "@media(max-width:640px)" in CSS or "@media (max-width: 640px)" in CSS, \
|
||||||
|
"Missing @media(max-width:640px) breakpoint in style.css"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rightpanel_mobile_slide_over_css():
|
||||||
|
"""Right panel must have position:fixed slide-over CSS for mobile."""
|
||||||
|
# At max-width:900px the rightpanel should be position:fixed, off-screen right
|
||||||
|
assert "position:fixed" in CSS, \
|
||||||
|
"style.css must have position:fixed for rightpanel mobile slide-over"
|
||||||
|
assert ".rightpanel.mobile-open{right:0" in CSS or ".rightpanel.mobile-open {right:0" in CSS, \
|
||||||
|
".rightpanel.mobile-open must set right:0 to slide panel in from right"
|
||||||
|
assert "right:-320px" in CSS or "right: -320px" in CSS, \
|
||||||
|
"rightpanel must start off-screen (right:-320px) on mobile"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_overlay_present():
|
||||||
|
"""Mobile overlay element must exist for tap-to-close sidebar behavior."""
|
||||||
|
assert 'id="mobileOverlay"' in HTML, \
|
||||||
|
"#mobileOverlay element missing from index.html"
|
||||||
|
assert "mobile-overlay" in CSS, \
|
||||||
|
".mobile-overlay CSS rule missing from style.css"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_bottom_nav_present():
|
||||||
|
"""Mobile bottom navigation bar must be present."""
|
||||||
|
assert "mobile-bottom-nav" in HTML or "mobile-nav-btn" in HTML, \
|
||||||
|
"Mobile bottom nav (.mobile-bottom-nav or .mobile-nav-btn) missing from index.html"
|
||||||
|
assert "mobile-bottom-nav" in CSS, \
|
||||||
|
".mobile-bottom-nav CSS rule missing from style.css"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_files_button_present():
|
||||||
|
"""Mobile files toggle button (#btnMobileFiles) must be in HTML and CSS."""
|
||||||
|
assert 'id="btnMobileFiles"' in HTML, \
|
||||||
|
"#btnMobileFiles missing from index.html"
|
||||||
|
assert "mobile-files-btn" in CSS, \
|
||||||
|
".mobile-files-btn CSS missing from style.css"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Profile dropdown overflow ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_profile_dropdown_not_clipped_by_overflow():
|
||||||
|
"""Profile dropdown must not be inside an overflow:hidden or overflow-x:auto ancestor
|
||||||
|
without a higher z-index escape hatch.
|
||||||
|
|
||||||
|
The topbar-chips container uses overflow-x:auto on mobile, which creates a
|
||||||
|
stacking context that clips absolutely-positioned children. The profile dropdown
|
||||||
|
must use position:fixed on mobile OR the topbar-chips must not clip it.
|
||||||
|
"""
|
||||||
|
# The profile-chip wrapper must have position:relative so the dropdown can escape
|
||||||
|
assert 'id="profileChipWrap"' in HTML, \
|
||||||
|
"#profileChipWrap missing from index.html"
|
||||||
|
# Profile dropdown must have a z-index high enough to clear the topbar
|
||||||
|
assert ".profile-dropdown{" in CSS or ".profile-dropdown {" in CSS, \
|
||||||
|
".profile-dropdown CSS rule missing"
|
||||||
|
# z-index must be at least 200 (topbar is z-index:10)
|
||||||
|
m = re.search(r'\.profile-dropdown\{[^}]*z-index:(\d+)', CSS)
|
||||||
|
if m:
|
||||||
|
assert int(m.group(1)) >= 100, \
|
||||||
|
f".profile-dropdown z-index {m.group(1)} is too low — must be >= 100 to clear topbar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_topbar_chips_mobile_overflow():
|
||||||
|
"""topbar-chips must use overflow-x:auto on mobile for chip scrolling.
|
||||||
|
|
||||||
|
Chips (profile, workspace, model, files) must scroll horizontally on narrow
|
||||||
|
viewports rather than wrapping onto a second line which would break the topbar layout.
|
||||||
|
"""
|
||||||
|
# At narrow viewport, topbar-chips should scroll
|
||||||
|
assert "overflow-x:auto" in CSS or "overflow-x: auto" in CSS, \
|
||||||
|
"topbar-chips must have overflow-x:auto for mobile chip scrolling"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Workspace panel close ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_workspace_close_button_present():
|
||||||
|
"""Workspace panel must have a close/hide button accessible on mobile."""
|
||||||
|
# Either a dedicated mobile close button or the X button that closes the panel
|
||||||
|
has_close = (
|
||||||
|
'onclick="toggleMobileFiles()"' in HTML or
|
||||||
|
'toggleMobileFiles' in HTML
|
||||||
|
)
|
||||||
|
assert has_close, \
|
||||||
|
"toggleMobileFiles() must be wired to a button to close the workspace panel on mobile"
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_mobile_files_js_defined():
|
||||||
|
"""toggleMobileFiles() must be defined in boot.js."""
|
||||||
|
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||||
|
assert "function toggleMobileFiles()" in boot_js, \
|
||||||
|
"toggleMobileFiles() missing from static/boot.js"
|
||||||
|
assert "mobile-open" in boot_js, \
|
||||||
|
"toggleMobileFiles() must toggle mobile-open class on the right panel"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Viewport and scroll safety ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_body_overflow_hidden():
|
||||||
|
"""body must have overflow:hidden to prevent double scrollbars on mobile."""
|
||||||
|
assert "body{" in CSS or "body {" in CSS, \
|
||||||
|
"body rule missing from style.css"
|
||||||
|
assert re.search(r'body\{[^}]*overflow:hidden', CSS), \
|
||||||
|
"body must have overflow:hidden to prevent double scrollbars"
|
||||||
|
|
||||||
|
|
||||||
|
def test_100dvh_viewport_height():
|
||||||
|
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing.
|
||||||
|
|
||||||
|
On mobile Safari and Chrome, 100vh includes the browser chrome (address bar),
|
||||||
|
causing content to be hidden. 100dvh accounts for the actual available height.
|
||||||
|
"""
|
||||||
|
assert "100dvh" in CSS, \
|
||||||
|
"style.css must use 100dvh for correct mobile viewport height (100vh hides content under address bar)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_composer_touch_target_size():
|
||||||
|
"""Send button and composer inputs must have minimum 44px touch targets on mobile.
|
||||||
|
|
||||||
|
Apple HIG and Google Material guidelines both require 44px minimum touch targets.
|
||||||
|
"""
|
||||||
|
# Check that mobile CSS doesn't make the send button smaller than 44×44
|
||||||
|
# We check that there's at least a min-height definition for touch targets
|
||||||
|
assert re.search(r'(min-height|height).*44px', CSS), \
|
||||||
|
"style.css must define 44px minimum touch targets for mobile (send button, nav buttons)"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Input zoom prevention ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_composer_textarea_font_size_mobile():
|
||||||
|
"""Composer textarea must have font-size >= 16px on mobile.
|
||||||
|
|
||||||
|
iOS Safari zooms the viewport when an input with font-size < 16px is focused,
|
||||||
|
which breaks the layout. The composer textarea must be >= 16px at mobile widths.
|
||||||
|
"""
|
||||||
|
# Check for 16px font-size on the textarea in a mobile breakpoint
|
||||||
|
assert re.search(r'font-size:16px', CSS), \
|
||||||
|
"Composer textarea must have font-size:16px at mobile widths to prevent iOS zoom-on-focus"
|
||||||
@@ -378,3 +378,46 @@ def test_custom_endpoint_uses_model_config_api_key_for_model_discovery(monkeypat
|
|||||||
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
|
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
|
||||||
assert 'Custom' in groups
|
assert 'Custom' in groups
|
||||||
assert 'gpt-5.2' in groups['Custom']
|
assert 'gpt-5.2' in groups['Custom']
|
||||||
|
|
||||||
|
|
||||||
|
# -- Issue #230: custom provider with slash model name -----------------------
|
||||||
|
|
||||||
|
def test_custom_endpoint_slash_model_routes_to_custom_not_openrouter():
|
||||||
|
"""Regression test for #230.
|
||||||
|
|
||||||
|
When provider=custom (or any non-openrouter provider) and base_url is set,
|
||||||
|
a model name containing a slash (e.g. google/gemma-4-26b-a4b) must NOT be
|
||||||
|
rerouted to OpenRouter -- it should stay on the configured custom endpoint.
|
||||||
|
"""
|
||||||
|
# --- custom provider with slash model name should NOT go to openrouter ---
|
||||||
|
model, provider, base_url = _resolve_with_config(
|
||||||
|
'google/gemma-4-26b-a4b',
|
||||||
|
provider='custom',
|
||||||
|
base_url='http://127.0.0.1:1234/v1',
|
||||||
|
default='google/gemma-4-26b-a4b',
|
||||||
|
)
|
||||||
|
assert provider.startswith('custom'), (
|
||||||
|
"Expected provider starting with 'custom', got '{}'. "
|
||||||
|
"Slash in model name should NOT trigger OpenRouter rerouting when base_url is set.".format(provider)
|
||||||
|
)
|
||||||
|
assert base_url == 'http://127.0.0.1:1234/v1', (
|
||||||
|
"Expected base_url 'http://127.0.0.1:1234/v1', got '{}'.".format(base_url)
|
||||||
|
)
|
||||||
|
assert model == 'google/gemma-4-26b-a4b', (
|
||||||
|
"Model name should be preserved as-is, got '{}'.".format(model)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- openrouter with slash model name MUST still route to openrouter -----
|
||||||
|
model_or, provider_or, _ = _resolve_with_config(
|
||||||
|
'google/gemma-4-26b-a4b',
|
||||||
|
provider='openrouter',
|
||||||
|
base_url='https://openrouter.ai/api/v1',
|
||||||
|
default='google/gemma-4-26b-a4b',
|
||||||
|
)
|
||||||
|
assert provider_or == 'openrouter', (
|
||||||
|
"Expected provider 'openrouter', got '{}'. "
|
||||||
|
"Slash model via openrouter provider must still resolve to openrouter.".format(provider_or)
|
||||||
|
)
|
||||||
|
assert model_or == 'google/gemma-4-26b-a4b', (
|
||||||
|
"Model name should be preserved for openrouter, got '{}'.".format(model_or)
|
||||||
|
)
|
||||||
|
|||||||
@@ -472,3 +472,24 @@ def test_upload_error_has_no_trace_field():
|
|||||||
assert "trace" not in body, \
|
assert "trace" not in body, \
|
||||||
"Upload errors must not leak stack traces to clients"
|
"Upload errors must not leak stack traces to clients"
|
||||||
assert "error" in body, "Error responses must include an 'error' key"
|
assert "error" in body, "Error responses must include an 'error' key"
|
||||||
|
|
||||||
|
|
||||||
|
# ── #248: /skills slash command ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_skills_slash_command_defined():
|
||||||
|
"""#248: /skills command must be registered in COMMANDS and implemented.
|
||||||
|
Verifies the command entry, function definition, and i18n key are all present.
|
||||||
|
"""
|
||||||
|
src = (REPO_ROOT / "static/commands.js").read_text()
|
||||||
|
|
||||||
|
# 1. 'skills' must appear in the COMMANDS array definition
|
||||||
|
assert "name:'skills'" in src or 'name:"skills"' in src, \
|
||||||
|
"COMMANDS array must include an entry with name:'skills'"
|
||||||
|
|
||||||
|
# 2. cmdSkills function must be defined
|
||||||
|
assert "function cmdSkills" in src, \
|
||||||
|
"cmdSkills function must be defined in commands.js"
|
||||||
|
|
||||||
|
# 3. i18n key cmd_skills must be referenced (wired to COMMANDS entry)
|
||||||
|
assert "cmd_skills" in src, \
|
||||||
|
"cmd_skills i18n key must be referenced in commands.js"
|
||||||
|
|||||||
@@ -700,10 +700,20 @@ def test_style_css_active_session_uses_gold(cleanup_test_sessions):
|
|||||||
"Active session gold color (#e8a030) not found in style.css"
|
"Active session gold color (#e8a030) not found in style.css"
|
||||||
|
|
||||||
|
|
||||||
def test_sessions_js_active_skips_project_border(cleanup_test_sessions):
|
def test_sessions_js_uses_action_menu_not_per_row_buttons(cleanup_test_sessions):
|
||||||
"""sessions.js must not override active session border-left with project color."""
|
"""sessions.js must use the single ⋯ action menu instead of per-row buttons.
|
||||||
|
|
||||||
|
The per-row button overlay was replaced with a single ⋯ trigger that opens a
|
||||||
|
positioned dropdown (session-action-menu). This removes the borderLeftColor
|
||||||
|
project colour override that the old code applied, which was the original
|
||||||
|
concern this test guarded. The new design uses a dot indicator for project
|
||||||
|
membership instead.
|
||||||
|
"""
|
||||||
src = REPO_ROOT / "static" / "sessions.js"
|
src = REPO_ROOT / "static" / "sessions.js"
|
||||||
code = src.read_text()
|
code = src.read_text()
|
||||||
# The fix: only set borderLeftColor if NOT the active session
|
assert "session-actions-trigger" in code, "session-actions-trigger not found in sessions.js"
|
||||||
assert "isActive" in code, "isActive check not found in sessions.js"
|
assert "_openSessionActionMenu" in code, "_openSessionActionMenu not found in sessions.js"
|
||||||
assert "borderLeftColor" in code, "borderLeftColor not found in sessions.js"
|
assert "closeSessionActionMenu" in code, "closeSessionActionMenu not found in sessions.js"
|
||||||
|
# The old per-row buttons must not be present (they were replaced by the menu)
|
||||||
|
assert "act-pin" not in code, "old act-pin per-row button still in sessions.js"
|
||||||
|
assert "act-archive" not in code, "old act-archive per-row button still in sessions.js"
|
||||||
|
|||||||
59
tests/test_sprint33.py
Normal file
59
tests/test_sprint33.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Sprint 33 Tests: Shared app dialogs replace native confirm/prompt usage.
|
||||||
|
|
||||||
|
These tests verify the static assets expose the reusable confirm/input modal
|
||||||
|
and that browser-native confirm/prompt calls are no longer used in the Web UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
REPO = pathlib.Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
return (REPO / path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_has_shared_app_dialog_markup():
|
||||||
|
html = read("static/index.html")
|
||||||
|
assert 'id="appDialogOverlay"' in html
|
||||||
|
assert 'id="appDialog"' in html
|
||||||
|
assert 'id="appDialogTitle"' in html
|
||||||
|
assert 'id="appDialogDesc"' in html
|
||||||
|
assert 'id="appDialogInput"' in html
|
||||||
|
assert 'id="appDialogCancel"' in html
|
||||||
|
assert 'id="appDialogConfirm"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_dialog_css_rules_exist():
|
||||||
|
css = read("static/style.css")
|
||||||
|
for selector in (
|
||||||
|
".app-dialog-overlay",
|
||||||
|
".app-dialog",
|
||||||
|
".app-dialog-input",
|
||||||
|
".app-dialog-actions",
|
||||||
|
".app-dialog-btn.confirm",
|
||||||
|
".app-dialog-btn.confirm.danger",
|
||||||
|
):
|
||||||
|
assert selector in css, f"missing CSS selector: {selector}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_js_exposes_shared_dialog_helpers():
|
||||||
|
src = read("static/ui.js")
|
||||||
|
assert "function showConfirmDialog(opts={})" in src
|
||||||
|
assert "function showPromptDialog(opts={})" in src
|
||||||
|
assert "document.addEventListener('keydown'" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_native_confirm_calls_remain_in_static_js():
|
||||||
|
for path in (REPO / "static").glob("*.js"):
|
||||||
|
src = path.read_text(encoding="utf-8")
|
||||||
|
assert not re.search(r"\bconfirm\s*\(", src), f"native confirm() remains in {path.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_native_prompt_calls_remain_in_static_js():
|
||||||
|
for path in (REPO / "static").glob("*.js"):
|
||||||
|
src = path.read_text(encoding="utf-8")
|
||||||
|
assert not re.search(r"\bprompt\s*\(", src), f"native prompt() remains in {path.name}"
|
||||||
Reference in New Issue
Block a user