diff --git a/CHANGELOG.md b/CHANGELOG.md index b7040ecb..7ec4e50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### Fixed +- **Binary files in the workspace no longer truncate when served on Windows.** The shared anchored file-open helper omitted the platform's `O_BINARY` flag, so on Windows a binary workspace file (video, image, archive) was read in text mode and truncated at its first `0x1A` (Ctrl-Z) byte — a 5.2 MB video could arrive as a few hundred bytes, breaking Range requests and playback. Non-directory anchored opens now set `O_BINARY` where the platform provides it (a no-op on Linux/macOS). Thanks @allenliang2022. (#7077) + +- **Unstyled native form controls now use the UI font.** Bare `button`, `input`, `select`, and `textarea` elements fell back to the browser default font instead of the app's UI font, and the markdown table filter lost the UI font after its `font: inherit` shorthand. Both now route through `--font-ui`; mono, conversation, and skin-scoped controls are unchanged. Thanks @starship-s. (#6871) + +- **Mobile navigation action shortcuts keep their rail order.** In the mobile sidebar, mirrored navigation actions could land out of order relative to the desktop rail after a native-tab or profile reorder. They now reconcile into rail-source order with no needless DOM moves. Thanks @starship-s. (#6909) + - **The Workspace Artifacts panel no longer keeps a stale entry after a session recovers, and no longer flashes another session's artifacts into the panel you're reading.** After a settled-session recovery the Artifacts panel could keep showing a moved or deleted artifact as a dead link until you manually switched sessions, and a background session finishing could momentarily repaint the foreground session's panel. Artifact re-projection is now scoped to the current session and pane (a background session's completion no longer touches the panel you're viewing), and the current session's panel refreshes correctly after recovery. Thanks @rodboev. (#6867) - **Kanban task and board dialogs stay usable in short windows.** On a short window (short-wide landscape, small laptop, or a phone in landscape) the Kanban create/edit-task and create-board dialogs were taller than the viewport with no height cap and no internal scroll, so the dialog overflowed both the top and bottom edges and its action buttons (Create / Save / Cancel) were unreachable. The dialogs now cap their height to the viewport (`calc(100dvh - 48px)`, with a `100vh` fallback) and scroll their content internally, and the overlay uses safe centering so the top stays reachable when the dialog is taller than the window. Normal-height dialogs are unchanged. Thanks @rodboev. (#6906) diff --git a/api/workspace.py b/api/workspace.py index 8f625c86..a88aee5f 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -1016,6 +1016,7 @@ def safe_resolve_ws(root: Path, requested: str) -> Path: _DIR_FD_OK = os.open in getattr(os, "supports_dir_fd", set()) _O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) _O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) +_O_BINARY = getattr(os, "O_BINARY", 0) def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int: @@ -1037,7 +1038,11 @@ def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int: # Windows / no openat: fall back to a plain pathname open. No new race # protection, but no regression vs the prior path-based behaviour, and # symlink creation needs admin on Windows anyway. - flags = os.O_RDONLY | (_O_DIRECTORY if want_dir else 0) | _O_NOFOLLOW + flags = ( + os.O_RDONLY + | (_O_DIRECTORY if want_dir else _O_BINARY) + | _O_NOFOLLOW + ) try: return os.open(str(target), flags) except OSError: @@ -1052,7 +1057,11 @@ def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int: for i, part in enumerate(rel_parts): is_last = i == len(rel_parts) - 1 want_directory = (not is_last) or want_dir - flags = os.O_RDONLY | _O_NOFOLLOW | (_O_DIRECTORY if want_directory else 0) + flags = ( + os.O_RDONLY + | _O_NOFOLLOW + | (_O_DIRECTORY if want_directory else _O_BINARY) + ) try: nfd = os.open(part, flags, dir_fd=fd) except OSError: diff --git a/static/style.css b/static/style.css index 28adc635..86dbcfd2 100644 --- a/static/style.css +++ b/static/style.css @@ -26,6 +26,7 @@ --border-subtle:rgba(0,0,0,.08);--border-muted:rgba(0,0,0,.12); font-family:var(--font-ui);font-size:14px;line-height:1.6; } + button,input,select,textarea{font-family:var(--font-ui);} /* ── Font size modifiers ── */ /* ── Font size preference: scale key UI text elements ── */ /* Default is 14px (no attribute needed). Small=12px, Large=16px, Extra Large=18px. */ @@ -2415,7 +2416,7 @@ .msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);} .msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);} .msg-body tr:nth-child(even){background:rgba(255,255,255,.03);} - .markdown-table-filter{display:block;width:min(260px,100%);margin:8px 0 4px;padding:5px 8px;border:1px solid var(--border2);border-radius:6px;background:var(--input-bg);color:var(--text);font:inherit;font-size:12px;} + .markdown-table-filter{display:block;width:min(260px,100%);margin:8px 0 4px;padding:5px 8px;border:1px solid var(--border2);border-radius:6px;background:var(--input-bg);color:var(--text);font:inherit;font-family:var(--font-ui);font-size:12px;} .markdown-table-filter:focus{outline:1px solid var(--accent);outline-offset:1px;} .markdown-table-sort{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;min-height:20px;padding:0;border:0;background:transparent;color:inherit;font:inherit;font-weight:inherit;text-align:left;cursor:pointer;} .markdown-table-sort:focus-visible{outline:1px solid var(--accent);outline-offset:2px;} diff --git a/static/ui.js b/static/ui.js index 05bd29b0..47942122 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1774,13 +1774,15 @@ function _syncNavActionMirrors(){ const rail=document.querySelector('.rail'); const sidebar=document.querySelector('.sidebar-nav'); if(!rail||!sidebar)return; + const anchor=sidebar.querySelector('.dashboard-link,[data-dashboard-link]')||sidebar.querySelector('[data-panel="logs"]'); const sources=Array.from(rail.querySelectorAll('.nav-tab:not([data-panel]):not([data-dashboard-link])')).filter(source=>source.id); const mirrors=Array.from(sidebar.querySelectorAll('[data-nav-action-mirror]')); const sourceIds=new Set(sources.map(source=>source.id)); mirrors.forEach(mirror=>{ if(!sourceIds.has(mirror.getAttribute('data-nav-action-mirror')))mirror.remove(); }); - sources.forEach(source=>{ + let next=anchor||null; + sources.slice().reverse().forEach(source=>{ const sourceVisible=(()=>{ if(source.hidden||source.getAttribute('aria-hidden')==='true')return false; if(source.classList.contains('nav-tab-hidden'))return false; @@ -1804,12 +1806,12 @@ function _syncNavActionMirrors(){ if(mirror._navActionSource)mirror._navActionSource.click(); if(typeof closeMobileSidebar==='function')closeMobileSidebar(); }); - const anchor=sidebar.querySelector('.dashboard-link,[data-dashboard-link]')||sidebar.querySelector('[data-panel="logs"]'); - sidebar.insertBefore(mirror,anchor||null); }else{ mirror.innerHTML=source.innerHTML; _stripInlineEventHandlers(mirror); } + if(mirror.parentNode!==sidebar||mirror.nextElementSibling!==next)sidebar.insertBefore(mirror,next); + next=mirror; mirror._navActionSource=source; mirror.classList.toggle('nav-action-visible',sourceVisible); const label=source.getAttribute('data-tooltip')||source.getAttribute('aria-label')||''; diff --git a/tests/test_dashboard_link_ui.py b/tests/test_dashboard_link_ui.py index 8fc4c91c..a077af2e 100644 --- a/tests/test_dashboard_link_ui.py +++ b/tests/test_dashboard_link_ui.py @@ -462,7 +462,17 @@ def test_extension_rail_actions_are_mirrored_to_mobile_nav(): }; } appendChild(child) { child.parentNode = this; this.children.push(child); return child; } + get nextElementSibling() { + if (!this.parentNode) return null; + const index = this.parentNode.children.indexOf(this); + return index >= 0 ? this.parentNode.children[index + 1] || null : null; + } insertBefore(child, anchor) { + if (child.parentNode) this.insertBeforeMoves = (this.insertBeforeMoves || 0) + 1; + if (child.parentNode) { + const currentIdx = child.parentNode.children.indexOf(child); + if (currentIdx >= 0) child.parentNode.children.splice(currentIdx, 1); + } const idx = anchor ? this.children.indexOf(anchor) : -1; child.parentNode = this; if (idx >= 0) this.children.splice(idx, 0, child); @@ -507,6 +517,7 @@ def test_extension_rail_actions_are_mirrored_to_mobile_nav(): return []; } querySelector(sel) { + if (sel.startsWith('.nav-tab[data-panel="')) return this.children.find(el => el.classList.contains('nav-tab') && el.getAttribute('data-panel') === sel.slice(21, -2)) || null; if (sel.startsWith('[data-nav-action-mirror="')) { const id = sel.slice(25, -2); return this.children.find(el => el.getAttribute('data-nav-action-mirror') === id) || null; @@ -520,7 +531,10 @@ def test_extension_rail_actions_are_mirrored_to_mobile_nav(): const rail = new El('nav'); const sidebar = new El('div'); const source = new El('button'); + const source2 = new El('button'); const dashboard = new El('button'); + const chatPanel = new El('button'); + const settingsPanel = new El('button'); let clicked = 0; let sidebarClosed = 0; source.id = 'hwxThemeCreatorRailBtn'; @@ -530,8 +544,21 @@ def test_extension_rail_actions_are_mirrored_to_mobile_nav(): source.onclick = () => { clicked += 100; }; source.innerHTML = ''; source.click = () => { clicked += 1; }; + source2.id = 'hwxTypographyRailBtn'; + source2.classList.add('rail-btn', 'nav-tab', 'has-tooltip'); + source2.setAttribute('data-tooltip', 'Typography'); + source2.innerHTML = ''; + chatPanel.id = 'chatMobileBtn'; + chatPanel.classList.add('nav-tab'); + chatPanel.setAttribute('data-panel', 'chat'); + settingsPanel.id = 'settingsMobileBtn'; + settingsPanel.classList.add('nav-tab'); + settingsPanel.setAttribute('data-panel', 'settings'); dashboard.classList.add('dashboard-link'); + dashboard.id = 'dashboardMobileBtn'; + sidebar.appendChild(chatPanel); sidebar.appendChild(dashboard); + sidebar.appendChild(settingsPanel); let observerCallback = null; let observedRail = null; let observedOptions = null; @@ -561,17 +588,53 @@ def test_extension_rail_actions_are_mirrored_to_mobile_nav(): const helpers = Function(extractFn('_stripInlineEventHandlers') + '\\n' + extractFn('_syncNavActionMirrors') + '\\n' + extractFn('_initNavActionMirrors') + '; return { _initNavActionMirrors };')(); helpers._initNavActionMirrors(); rail.appendChild(source); + rail.appendChild(source2); observerCallback(); const mirror = sidebar.children.find(el => el.getAttribute('data-nav-action-mirror') === 'hwxThemeCreatorRailBtn'); + const mirror2 = sidebar.children.find(el => el.getAttribute('data-nav-action-mirror') === 'hwxTypographyRailBtn'); source.hidden = true; observerCallback(); const mirrorHiddenWhenSourceHidden = !mirror.classList.contains('nav-action-visible'); source.hidden = false; observerCallback(); mirror.click(); - const mirrorBeforeDashboard = sidebar.children[0] === mirror; + const mirrorBeforeDashboard = sidebar.children[1] === mirror && sidebar.children[2] === mirror2 && sidebar.children[3] === dashboard; + const initialOrder = sidebar.children.map(el => el.id); + const tasksPanel = new El('button'); + tasksPanel.id = 'tasksMobileBtn'; + tasksPanel.classList.add('nav-tab'); + tasksPanel.setAttribute('data-panel', 'tasks'); + const logsPanel = new El('button'); + logsPanel.id = 'logsMobileBtn'; + logsPanel.classList.add('nav-tab'); + logsPanel.setAttribute('data-panel', 'logs'); + sidebar.appendChild(tasksPanel); + sidebar.appendChild(logsPanel); + ['tasks', 'logs'].forEach(panel => { + const node = sidebar.querySelector('.nav-tab[data-panel="' + panel + '"]'); + if (node) sidebar.insertBefore(node, dashboard); + }); + const preSyncOrder = sidebar.children.map(el => el.id); + const movesBeforeReconcile = sidebar.insertBeforeMoves || 0; + observerCallback(); + const reconciledOrder = sidebar.children.map(el => el.id); + const reconciliationMoves = (sidebar.insertBeforeMoves || 0) - movesBeforeReconcile; + const movesBeforeNoOpSync = sidebar.insertBeforeMoves || 0; + observerCallback(); + const noOpSyncMoves = (sidebar.insertBeforeMoves || 0) - movesBeforeNoOpSync; source.remove(); observerCallback(); + const mirrorRemoved = !sidebar.children.some(el => el.getAttribute('data-nav-action-mirror') === 'hwxThemeCreatorRailBtn'); + dashboard.remove(); + logsPanel.remove(); + const source3 = new El('button'); + source3.id = 'hwxNoAnchorRailBtn'; + source3.classList.add('rail-btn', 'nav-tab'); + source3.setAttribute('aria-label', 'No anchor'); + rail.appendChild(source3); + observerCallback(); + const noAnchorMirror = sidebar.children.find(el => el.getAttribute('data-nav-action-mirror') === 'hwxNoAnchorRailBtn'); + const noAnchorMirrorAppended = noAnchorMirror?.parentNode === sidebar && sidebar.children.at(-1) === noAnchorMirror; console.log(JSON.stringify({ observerArmed: observedRail === rail, observerAttributes: !!(observedOptions && observedOptions.attributes), @@ -584,7 +647,13 @@ def test_extension_rail_actions_are_mirrored_to_mobile_nav(): mirrorOnclickAttribute: mirror.getAttribute('onclick'), mirrorOnclickProperty: mirror.onclick === null, mirrorBeforeDashboard, - mirrorRemoved: !sidebar.children.some(el => el.getAttribute('data-nav-action-mirror') === 'hwxThemeCreatorRailBtn'), + initialOrder, + preSyncOrder, + reconciledOrder, + reconciliationMoves, + noOpSyncMoves, + mirrorRemoved, + noAnchorMirrorAppended, clicked, sidebarClosed, })); @@ -610,7 +679,35 @@ def test_extension_rail_actions_are_mirrored_to_mobile_nav(): "mirrorOnclickAttribute": None, "mirrorOnclickProperty": True, "mirrorBeforeDashboard": True, + "initialOrder": [ + "chatMobileBtn", + "hwxThemeCreatorRailBtnMobile", + "hwxTypographyRailBtnMobile", + "dashboardMobileBtn", + "settingsMobileBtn", + ], + "preSyncOrder": [ + "chatMobileBtn", + "hwxThemeCreatorRailBtnMobile", + "hwxTypographyRailBtnMobile", + "tasksMobileBtn", + "logsMobileBtn", + "dashboardMobileBtn", + "settingsMobileBtn", + ], + "reconciledOrder": [ + "chatMobileBtn", + "tasksMobileBtn", + "logsMobileBtn", + "hwxThemeCreatorRailBtnMobile", + "hwxTypographyRailBtnMobile", + "dashboardMobileBtn", + "settingsMobileBtn", + ], + "reconciliationMoves": 2, + "noOpSyncMoves": 0, "mirrorRemoved": True, + "noAnchorMirrorAppended": True, "clicked": 1, "sidebarClosed": 1, } diff --git a/tests/test_media_message_snapshots.py b/tests/test_media_message_snapshots.py index 2f4a4512..c7384c0e 100644 --- a/tests/test_media_message_snapshots.py +++ b/tests/test_media_message_snapshots.py @@ -97,6 +97,72 @@ def test_capture_snapshot_stores_content_addressed_bytes(snap_dir, tmp_path): assert stored.read_bytes() == b"v1" +def test_anchored_snapshot_read_preserves_windows_ctrl_z(tmp_path): + """Anchored binary reads must not treat DOS Ctrl-Z as text EOF on Windows.""" + from api.routes import _etag_and_snapshot, _open_file_read_fd + + payload = b"a" * 568 + b"\x1a" + b"b" * 4096 + source = tmp_path / "clip.mp4" + source.write_bytes(payload) + + fd = _open_file_read_fd(source, tmp_path) + try: + _etag, snapshot, actual_size = _etag_and_snapshot(fd, file_size=len(payload)) + finally: + os.close(fd) + + assert actual_size == len(payload) + assert snapshot == payload + + +def test_anchored_file_leaf_uses_binary_open_flag(monkeypatch, tmp_path): + """The platform-independent flag contract keeps Windows binary-safe in CI.""" + from api import workspace + + source = tmp_path / "clip.mp4" + source.write_bytes(b"payload") + seen = {} + + def fake_open(path, flags, *args, **kwargs): + seen["path"] = path + seen["flags"] = flags + return 123 + + monkeypatch.setattr(workspace, "_DIR_FD_OK", False) + monkeypatch.setattr(workspace, "_O_BINARY", 0x40000000) + monkeypatch.setattr(workspace.os, "open", fake_open) + + fd = workspace.open_anchored_fd(tmp_path, source, want_dir=False) + + assert fd == 123 + assert seen["path"] == str(source) + assert seen["flags"] & workspace._O_BINARY + + +def test_anchored_directory_does_not_use_binary_open_flag(monkeypatch, tmp_path): + """Directory opens keep their directory-only flag contract.""" + from api import workspace + + target = tmp_path / "folder" + target.mkdir() + seen = {} + + def fake_open(path, flags, *args, **kwargs): + seen["flags"] = flags + return 124 + + monkeypatch.setattr(workspace, "_DIR_FD_OK", False) + monkeypatch.setattr(workspace, "_O_BINARY", 0x40000000) + monkeypatch.setattr(workspace, "_O_DIRECTORY", 0x20000000) + monkeypatch.setattr(workspace.os, "open", fake_open) + + fd = workspace.open_anchored_fd(tmp_path, target, want_dir=True) + + assert fd == 124 + assert seen["flags"] & workspace._O_DIRECTORY + assert not seen["flags"] & workspace._O_BINARY + + def test_capture_snapshot_dedupes_identical_content(snap_dir, tmp_path): from api.media_snapshots import capture_snapshot diff --git a/tests/test_typography_contract.py b/tests/test_typography_contract.py index adc24237..f672bc91 100644 --- a/tests/test_typography_contract.py +++ b/tests/test_typography_contract.py @@ -158,12 +158,24 @@ def test_playwright_regression_ensures_font_contract_for_syntax_and_edit_surface + + + +

inline token sample.

fenced token
+ + + + +
row
+
+ +
""" @@ -206,6 +218,22 @@ def test_playwright_regression_ensures_font_contract_for_syntax_and_edit_surface "#previewEditArea", "el => getComputedStyle(el).fontFamily", ) + component_fonts = page.evaluate( + """ + () => Object.fromEntries( + ["markdownTableFilter", "markdownTableSort", "markdownTableCell", "detailFormTextarea"] + .map(id => [id, getComputedStyle(document.getElementById(id)).fontFamily]) + ) + """ + ) + native_control_fonts = page.evaluate( + """ + () => Object.fromEntries( + ["buttonSentinel", "inputSentinel", "selectSentinel", "textareaSentinel"] + .map(id => [id, getComputedStyle(document.getElementById(id)).fontFamily]) + ) + """ + ) runtime_token_font = page.evaluate(""" () => { const style = document.createElement('style'); @@ -248,6 +276,13 @@ def test_playwright_regression_ensures_font_contract_for_syntax_and_edit_surface assert "MonoFontSentinel" in fenced_code_font assert "ConvoFontSentinel" in message_edit_font assert "MonoFontSentinel" in preview_edit_font + for control_id, font in native_control_fonts.items(): + assert "UiFontSentinel" in font, f"{control_id} computed {font!r}" + assert "UiFontSentinel" in component_fonts["markdownTableFilter"] + assert "MonoFontSentinel" in component_fonts["markdownTableSort"] + assert "MonoFontSentinel" in component_fonts["markdownTableCell"] + assert component_fonts["markdownTableSort"] == component_fonts["markdownTableCell"] + assert "MonoFontSentinel" in component_fonts["detailFormTextarea"] assert "RuntimeMono" in runtime_token_font assert "EditedRuntimeMono" in edited_runtime_token_font assert "LinkedRuntimeMono" in linked_stylesheet_font