diff options
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | src/ollama_web/i18n/messages/en.json | 1 | ||||
| -rw-r--r-- | src/ollama_web/i18n/messages/ja.json | 1 | ||||
| -rw-r--r-- | src/ollama_web/static/app.js | 73 | ||||
| -rw-r--r-- | src/ollama_web/static/style.css | 30 | ||||
| -rw-r--r-- | src/ollama_web/templates/index.html | 21 | ||||
| -rw-r--r-- | tests/test_security.py | 29 |
7 files changed, 150 insertions, 9 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb3f92..3f4ab07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - 画像は vision 対応モデルだけへ渡し、非対応時は多言語の固定通知へ置き換えるようにしました。MCP 音声データはモデル capability にかかわらず Ollama へ渡しません。 - `web_search`、`scrape_url`、`search_and_fetch` をテキスト専用の契約に統一し、後二者は画像・音声・動画レスポンスを拒否するようにしました。 +### Fixed + +- スマートフォンやタッチ端末でセッション削除ボタンを押せない問題を修正し、hoverに依存しない表示、単一のイベント委譲、アプリ内確認ダイアログへ変更しました。 + ### Security - MCP の base64 メディアをテキスト結果から分離し、MIME type・件数・サイズを制限しました。メディア配信は認証、セッションスコープ、MIME allowlist、`media-src 'self'` を適用します。 diff --git a/src/ollama_web/i18n/messages/en.json b/src/ollama_web/i18n/messages/en.json index 878e53c..e20fbd8 100644 --- a/src/ollama_web/i18n/messages/en.json +++ b/src/ollama_web/i18n/messages/en.json @@ -5,6 +5,7 @@ "clear": "Clear", "save": "Save", "delete": "Delete", + "cancel": "Cancel", "close": "Close", "copy": "Copy" }, diff --git a/src/ollama_web/i18n/messages/ja.json b/src/ollama_web/i18n/messages/ja.json index b39e85e..36094e5 100644 --- a/src/ollama_web/i18n/messages/ja.json +++ b/src/ollama_web/i18n/messages/ja.json @@ -5,6 +5,7 @@ "clear": "クリア", "save": "保存", "delete": "削除", + "cancel": "キャンセル", "close": "閉じる", "copy": "コピー" }, diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index 753ee99..a498e37 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -15,6 +15,10 @@ const newSessionBtn = document.getElementById("new-session-btn"); const menuBtn = document.getElementById("menu-btn"); const sidebar = document.getElementById("sidebar"); const sidebarBackdrop = document.getElementById("sidebar-backdrop"); +const deleteSessionModal = document.getElementById("delete-session-modal"); +const deleteSessionCancelBtn = document.getElementById("delete-session-cancel"); +const deleteSessionConfirmBtn = document.getElementById("delete-session-confirm"); +const deleteSessionStatus = document.getElementById("delete-session-status"); const TOOLS = window.TOOLS || []; let currentSessionId = null; @@ -23,6 +27,8 @@ let pendingFiles = []; let abortController = null; let currentCapabilities = new Set(); let stickToBottom = true; +let pendingDeleteSessionId = null; +let deleteSessionTrigger = null; function updateThinkToggleState() { if (!thinkToggle) return; @@ -308,15 +314,12 @@ function renderSessionList(sessions) { title.className = "title"; title.textContent = s.title || s.id; const del = document.createElement("button"); + del.type = "button"; del.className = "delete"; del.textContent = t("common.delete"); del.title = t("chat.delete_session"); - del.addEventListener("click", (e) => { - e.stopPropagation(); - deleteSession(s.id); - }); + del.setAttribute("aria-label", t("chat.delete_session")); li.append(title, del); - li.addEventListener("click", () => selectSession(s.id)); sessionListEl.appendChild(li); } } @@ -334,8 +337,28 @@ async function createSession() { } } +function openDeleteSessionDialog(id, trigger) { + pendingDeleteSessionId = id; + deleteSessionTrigger = trigger; + deleteSessionStatus.textContent = ""; + deleteSessionConfirmBtn.disabled = false; + deleteSessionModal.classList.add("open"); + deleteSessionModal.setAttribute("aria-hidden", "false"); + deleteSessionConfirmBtn.focus(); +} + +function closeDeleteSessionDialog({ restoreFocus = true } = {}) { + deleteSessionModal.classList.remove("open"); + deleteSessionModal.setAttribute("aria-hidden", "true"); + pendingDeleteSessionId = null; + deleteSessionStatus.textContent = ""; + if (restoreFocus && deleteSessionTrigger?.isConnected) { + deleteSessionTrigger.focus(); + } + deleteSessionTrigger = null; +} + async function deleteSession(id) { - if (!confirm(t("chat.delete_confirm"))) return; try { const res = await csrfFetch(`/api/sessions/${id}`, { method: "DELETE" }); if (!res.ok) throw new Error(res.statusText); @@ -348,9 +371,11 @@ async function deleteSession(id) { updateTokenGauge(0, 0); } await loadSessions(); + return true; } catch (err) { console.error("failed to delete session", err); - alert(t("chat.delete_failed")); + deleteSessionStatus.textContent = t("chat.delete_failed"); + return false; } } @@ -583,13 +608,45 @@ sidebarBackdrop.addEventListener("click", () => { }); sessionListEl.addEventListener("click", (e) => { + const deleteButton = e.target.closest(".delete"); + if (deleteButton) { + e.preventDefault(); + e.stopPropagation(); + const li = deleteButton.closest("li"); + if (li?.dataset.id) openDeleteSessionDialog(li.dataset.id, deleteButton); + return; + } const li = e.target.closest("li"); - if (li && li.dataset.id && !e.target.closest(".delete")) { + if (li?.dataset.id) { + selectSession(li.dataset.id); sidebar.classList.remove("open"); sidebarBackdrop.classList.remove("active"); } }); +deleteSessionCancelBtn.addEventListener("click", () => closeDeleteSessionDialog()); + +deleteSessionConfirmBtn.addEventListener("click", async () => { + const id = pendingDeleteSessionId; + if (!id || deleteSessionConfirmBtn.disabled) return; + deleteSessionConfirmBtn.disabled = true; + deleteSessionModal.setAttribute("aria-busy", "true"); + const deleted = await deleteSession(id); + deleteSessionModal.removeAttribute("aria-busy"); + deleteSessionConfirmBtn.disabled = false; + if (deleted) closeDeleteSessionDialog({ restoreFocus: false }); +}); + +deleteSessionModal.addEventListener("click", (e) => { + if (e.target === deleteSessionModal) closeDeleteSessionDialog(); +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && deleteSessionModal.classList.contains("open")) { + closeDeleteSessionDialog(); + } +}); + modelSelect.addEventListener("change", loadModelCapabilities); async function send() { diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css index 2b6d4bc..beb65d1 100644 --- a/src/ollama_web/static/style.css +++ b/src/ollama_web/static/style.css @@ -282,6 +282,20 @@ body { display: inline; } +/* Touch devices cannot reliably reveal hover-only controls. Keep the action + visible and large enough to tap without changing the desktop interaction. */ +@media (max-width: 768px), (hover: none), (pointer: coarse) { + #session-list li .delete { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + flex-shrink: 0; + touch-action: manipulation; + } +} + #session-list li.empty { color: var(--muted); font-size: 13px; @@ -692,6 +706,16 @@ button#send-btn { padding: 0 20px; } flex-direction: column; } +.confirm-modal-content { + width: min(420px, 100%); +} + +.delete-session-status { + min-height: 20px; + color: var(--danger); + font-size: 13px; +} + .modal-header { display: flex; align-items: center; @@ -755,6 +779,12 @@ button#send-btn { padding: 0 20px; } background: transparent; } +.modal-footer button.danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} + .mcp-server-card { background: var(--bg); border: 1px solid var(--border); diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html index 54202e3..86f1ee1 100644 --- a/src/ollama_web/templates/index.html +++ b/src/ollama_web/templates/index.html @@ -76,6 +76,25 @@ </form> </div> + <div id="delete-session-modal" class="modal" aria-hidden="true"> + <div class="modal-content confirm-modal-content" role="dialog" aria-modal="true" + aria-labelledby="delete-session-modal-title" + aria-describedby="delete-session-modal-message"> + <div class="modal-header"> + <h2 id="delete-session-modal-title">{{ t('chat.delete_session') }}</h2> + </div> + <div class="modal-body"> + <p id="delete-session-modal-message" class="modal-help">{{ t('chat.delete_confirm') }}</p> + <div id="delete-session-status" class="delete-session-status" role="status" + aria-live="polite"></div> + </div> + <div class="modal-footer"> + <button id="delete-session-cancel" type="button" class="secondary">{{ t('common.cancel') }}</button> + <button id="delete-session-confirm" type="button" class="danger">{{ t('common.delete') }}</button> + </div> + </div> + </div> + <div id="settings-modal" class="modal" aria-hidden="true"> <div class="modal-content settings-modal-content" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title"> @@ -202,4 +221,4 @@ <script src="{{ url_for('static', path='settings-actions.js') }}"></script> <script src="{{ url_for('static', path='app.js') }}"></script> </body> -</html>
\ No newline at end of file +</html> diff --git a/tests/test_security.py b/tests/test_security.py index dea165b..7ac59fd 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -113,6 +113,35 @@ def test_katex_local_font_assets_are_present() -> None: assert not [path for path in font_paths if not (vendor_dir / path).exists()] +def test_session_delete_action_is_visible_and_touch_sized_on_mobile() -> None: + css_path = Path(__file__).parents[1] / "src" / "ollama_web" / "static" / "style.css" + css = css_path.read_text(encoding="utf-8") + base_rule = css.index("#session-list li .delete {") + touch_rule = css.index("@media (max-width: 768px), (hover: none), (pointer: coarse)") + touch_block = css[touch_rule : css.index("#session-list li.empty", touch_rule)] + + # The touch override must follow the default display:none rule so it wins + # in the cascade without changing hover-capable desktop behavior. + assert touch_rule > base_rule + assert "display: inline-flex" in touch_block + assert "min-width: 44px" in touch_block + assert "min-height: 44px" in touch_block + + +def test_session_delete_uses_in_app_dialog_instead_of_native_confirm() -> None: + root = Path(__file__).parents[1] / "src" / "ollama_web" + template = (root / "templates" / "index.html").read_text(encoding="utf-8") + script = (root / "static" / "app.js").read_text(encoding="utf-8") + + assert 'id="delete-session-modal"' in template + assert 'role="dialog"' in template + assert 'id="delete-session-cancel"' in template + assert 'id="delete-session-confirm"' in template + assert "confirm(" not in script + assert 'sessionListEl.addEventListener("click"' in script + assert "openDeleteSessionDialog(li.dataset.id, deleteButton)" in script + + def test_authenticated_audio_content_supports_range_requests( workspace_tmp: Path, monkeypatch ) -> None: |
