From bb85f5228a243ede56256d951b59dd1bcce669cf Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 10 Nov 2026 17:04:20 -0500 Subject: [PATCH] Fix responsive V2 chat table layout Expand table-bearing assistant replies within the conversation, wrap long cell content, and contain remaining overflow in keyboard-accessible table regions. Add responsive regression coverage and fix documentation, and bump the app to 0.261.425. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../src/components/chat/AssistantMarkdown.tsx | 12 +- .../v2_ui/src/components/chat/MessageList.tsx | 3 +- application/v2_ui/src/styles/theme.css | 11 + .../fixes/V2_CHAT_TABLE_OVERFLOW_FIX.md | 117 +++++++++ functional_tests/test_v2_chat_table_layout.py | 63 +++++ ui_tests/test_v2_chat_table_layout.py | 230 ++++++++++++++++++ 7 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 docs/explanation/fixes/V2_CHAT_TABLE_OVERFLOW_FIX.md create mode 100644 functional_tests/test_v2_chat_table_layout.py create mode 100644 ui_tests/test_v2_chat_table_layout.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 77f9f7c89..1960c5b23 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -103,7 +103,7 @@ EXECUTOR_MAX_WORKERS = 30 CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32')) SESSION_TYPE = 'filesystem' -VERSION = "0.261.424" +VERSION = "0.261.425" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/chat/AssistantMarkdown.tsx b/application/v2_ui/src/components/chat/AssistantMarkdown.tsx index 5eaad16eb..0f4c670e7 100644 --- a/application/v2_ui/src/components/chat/AssistantMarkdown.tsx +++ b/application/v2_ui/src/components/chat/AssistantMarkdown.tsx @@ -235,6 +235,16 @@ function Markdown({ // out here would render the raw ⟦cite:N⟧ token as visible text. p: ({ children }) =>

{renderTokens(children)}

, li: ({ children }) =>
  • {renderTokens(children)}
  • , + table: ({ node: _node, ...props }) => ( +
    + + + ), td: ({ children }) => , th: ({ children }) => , h1: ({ children }) =>

    {renderTokens(children)}

    , @@ -374,7 +384,7 @@ function Markdown({ return (
    -
    +
    {/* Shown while connecting even when partial content is already on screen: without it, a response that stopped mid-sentence just looks frozen. */} {connecting && ( diff --git a/application/v2_ui/src/styles/theme.css b/application/v2_ui/src/styles/theme.css index 413d0695c..283cc5892 100644 --- a/application/v2_ui/src/styles/theme.css +++ b/application/v2_ui/src/styles/theme.css @@ -510,6 +510,17 @@ html[data-font-size='xl'] { } @layer utilities { + .chat-assistant-bubble:has(.chat-markdown-table) { + width: 100%; + max-width: 100%; + } + + .chat-markdown-table :is(th, td) { + min-width: clamp(3.5rem, 8vw, 8rem); + overflow-wrap: anywhere; + white-space: normal; + } + /* * The Admin Settings card and field rules below are shared with the V2 workflow editor * (SectionCard.tsx and WorkflowField.tsx), so a workflow card reads as the same object as an diff --git a/docs/explanation/fixes/V2_CHAT_TABLE_OVERFLOW_FIX.md b/docs/explanation/fixes/V2_CHAT_TABLE_OVERFLOW_FIX.md new file mode 100644 index 000000000..30abd8209 --- /dev/null +++ b/docs/explanation/fixes/V2_CHAT_TABLE_OVERFLOW_FIX.md @@ -0,0 +1,117 @@ +# V2 Chat Table Overflow Fix + +Fixed in version: **0.261.425** + +## Issue and root cause + +Wide tables in V2 assistant replies could extend past their message bubble. Long +identifiers, compact JSON, and URLs contributed an intrinsic table width larger +than the bubble's reading-width limit. Setting the table to `width: 100%` did not +override that minimum content width, and there was no table-local scroll +container. + +The normal bubble limits remain useful for prose: wide mode caps bubbles at +`min(64rem, 90%)`, and comfortable mode at `min(46rem, 85%)`. The defect was +applying those limits to evidence tables without handling their layout and +overflow separately. + +## Changes + +`application/v2_ui/src/components/chat/MessageList.tsx` gives saved assistant +bubbles and the streaming bubble a shared layout class and permits them to +shrink within their flex container. User bubbles retain their existing classes. + +`application/v2_ui/src/components/chat/AssistantMarkdown.tsx` wraps each Markdown +table in a bounded horizontal scroll region. The region is keyboard focusable, +has the accessible name **Table**, and shows a focus outline. The semantic +`table`, header cells, data cells, citation substitutions, and safe React +rendering remain intact. + +`application/v2_ui/src/styles/theme.css` expands only assistant bubbles that +contain a rendered Markdown table. They can fill the existing conversation +container rather than stopping at the normal bubble reading limit. Comfortable +mode still limits the conversation container; wide mode still allows it to fill +the pane. No preference is changed or persisted by this fix. + +Table cells wrap text, including long unbroken records, with +`overflow-wrap: anywhere`. A responsive minimum cell width of +`clamp(3.5rem, 8vw, 8rem)` prevents a short column from collapsing into a narrow +strip beside a long record. Column allocation otherwise remains automatic; the +fix does not impose equal-width columns or reduce the font size. + +`application/single_app/config.py` increments `VERSION` from `0.261.424` to +`0.261.425`. The new functional and UI test headers record that implementation +version. + +## User-visible behavior and boundaries + +Tables use available horizontal space, then wrap their content. If the columns +or non-wrapping content still cannot fit, the reader can scroll the table +horizontally without scrolling the conversation sideways. For example, the +four-column regression fixture fits at 393 pixels but needs local scrolling at +320 pixels when it includes a non-wrapping citation chip. The same text-only +table wraps without horizontal scrolling at 320 pixels. + +The shared rules apply as a streamed reply becomes a table, after finalization, +and when the saved message is loaded again. Plain prose messages keep their +existing reading widths. Classic chat, backend APIs, settings, browser assets, +and deployers are unchanged. + +Very narrow tables can become tall because content is retained rather than +truncated. Tables with many columns intentionally use the scroll fallback. +Validation used desktop Chromium, including emulated narrow viewports; it does +not constitute real-device or cross-browser qualification. + +## Regression coverage + +`functional_tests/test_v2_chat_table_layout.py` checks the saved/streaming layout +wiring, unchanged width preferences, rendered-table scoping, wrapping, keyboard +focus, and safe semantic Markdown rendering. + +`ui_tests/test_v2_chat_table_layout.py` runs production components, stores, and +production CSS through the existing V2 browser harness. It covers: + +- Evidence tables at 320, 393, 768, 1280, and 1920 pixels, in light/dark themes + and both reading-width preferences. +- Long identifiers, JSON in inline code, long links, and document citations. +- Table and bubble geometry, cell overflow, page/pane containment, minimum cell + width, unchanged table font size, and narrower ordinary messages. +- Resizing the same conversation, partial streaming, finalization, and remounting + the saved reply. +- A 30-column table whose final column is reached with keyboard scrolling. + +The shared browser fixtures support local Chromium or an existing Azure +Playwright workspace using `DefaultAzureCredential`. No cloud resources or +credentials are created by these tests. Screenshots are written to the ignored +`ui_tests/artifacts/v2-chat-tables/` directory. + +## Validation results + +The new desktop evidence-table regression failed against the original +implementation: at a 1920-pixel viewport, the table's right edge reached +approximately 4533 pixels while its bubble ended at 1040 pixels. After the fix, +the bubble expands within the conversation and the content stays within its +table scroll region. + +| Command | Result | +| --- | --- | +| `npm --prefix .\application\v2_ui run build -- --outDir ..\..\ui_tests\artifacts\orchestration-plan-editor` | Passed TypeScript compilation and production build; Vite reported its existing large-chunk warning. | +| `python .\functional_tests\test_v2_chat_table_layout.py` | 3 passed. | +| `python .\functional_tests\test_v2_mobile_layout.py` | 5 passed. | +| `python -m pytest .\ui_tests\test_v2_chat_table_layout.py .\ui_tests\test_v2_mobile_chat_layout.py -q --tb=short` | 18 passed: 9 table regressions and 9 existing mobile-layout regressions. | +| `python .\functional_tests\test_docs_site_quality.py` | 6 of 6 checks passed. | +| `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check` | Passed. | + +The adjacent `test_v2_orchestration_streaming_bubble.py` suite was also attempted. +It reported three assertion failures and eight teardown errors: its API stub +does not handle the current pending-question, credential-request, and user-input +reads, and three tests expect a **Review the running plan** button that is not +found. A representative failing test was rerun with all three production UI +files restored to their original source and reproduced both the missing-button +failure and API-stub errors. These unrelated tests were left unchanged. + +The required UI detector reported the existing activity-indicator +`animate-bounce` rule and a false positive matching the literal `` in an +accessibility comment. The actual adjacent image has a resolved `src` and an +error handler. Neither finding concerns the table changes; no detector +suppression or unrelated design change was added. diff --git a/functional_tests/test_v2_chat_table_layout.py b/functional_tests/test_v2_chat_table_layout.py new file mode 100644 index 000000000..de852d0d8 --- /dev/null +++ b/functional_tests/test_v2_chat_table_layout.py @@ -0,0 +1,63 @@ +# test_v2_chat_table_layout.py +""" +Functional contracts for responsive V2 assistant tables. +Version: 0.261.425 +Implemented in: 0.261.425 + +Pin shared rendering and scoped saved/streaming layout wiring. Real browser +geometry, content preservation and keyboard scrolling are covered in ui_tests. +""" + +from pathlib import Path +import re + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "application" / "v2_ui" / "src" + + +def test_saved_and_streaming_bubbles_share_table_layout_without_changing_user_widths(): + source = (SOURCE / "components" / "chat" / "MessageList.tsx").read_text("utf-8") + saved, streaming = source.split("function StreamingBubble()", 1) + assert "!isUser && 'chat-assistant-bubble min-w-0'" in saved + assert "'chat-assistant-bubble min-w-0'" in streaming + assert "bubbleWidthClass(chatWidth)" in saved + assert "bubbleWidthClass(chatWidth)" in streaming + widths = (SOURCE / "lib" / "chatWidth.ts").read_text("utf-8") + assert "'max-w-[min(64rem,90%)]' : 'max-w-[min(46rem,85%)]'" in widths + assert "'max-w-none' : 'max-w-4xl'" in widths + + +def test_table_expansion_is_scoped_to_rendered_tables_and_cells_can_wrap(): + css = (SOURCE / "styles" / "theme.css").read_text("utf-8") + bubble = re.search(r"\.chat-assistant-bubble:has\(\.chat-markdown-table\)\s*\{([^}]+)\}", css) + assert bubble is not None + assert "width: 100%;" in bubble.group(1) + assert "max-width: 100%;" in bubble.group(1) + cells = re.search(r"\.chat-markdown-table :is\(th, td\)\s*\{([^}]+)\}", css) + assert cells is not None + assert "min-width: clamp(3.5rem, 8vw, 8rem);" in cells.group(1) + assert "overflow-wrap: anywhere;" in cells.group(1) + assert "white-space: normal;" in cells.group(1) + assert "table-layout: fixed" not in cells.group(1) + + +def test_shared_markdown_table_wrapper_preserves_safe_semantic_rendering(): + source = (SOURCE / "components" / "chat" / "AssistantMarkdown.tsx").read_text("utf-8") + wrapper = source.split("table: ({ node: _node, ...props }) => (", 1)[1].split("td:", 1)[0] + assert "chat-markdown-table min-w-0 max-w-full overflow-x-auto" in wrapper + assert 'role="region"' in wrapper + assert 'aria-label="Table"' in wrapper + assert "tabIndex={0}" in wrapper + assert "
    {renderTokens(children)}{renderTokens(children)}
    " in wrapper + assert "focus-visible:outline-accent" in wrapper + assert "dangerouslySetInnerHTML" not in source + assert "rehype-raw" not in source.split("const components =", 1)[1] + assert "td: ({ children }) => " in source + assert "th: ({ children }) => " in source + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/ui_tests/test_v2_chat_table_layout.py b/ui_tests/test_v2_chat_table_layout.py new file mode 100644 index 000000000..0e80c9617 --- /dev/null +++ b/ui_tests/test_v2_chat_table_layout.py @@ -0,0 +1,230 @@ +# test_v2_chat_table_layout.py +""" +Real-component regressions for responsive V2 assistant tables. +Version: 0.261.425 +Implemented in: 0.261.425 + +Production MessageList, Markdown, stores and CSS run through the existing harness. +Only HTTP is stubbed. Shared fixtures support local and Azure Playwright. +""" + +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from test_v2_chat_message_scroll import ( + complete, + frames, + message, + mount, + push_content, + root, + start_stream, +) + + +pytest_plugins = ("test_v2_chat_message_scroll",) +pytestmark = pytest.mark.ui +ARTIFACTS = Path(__file__).parent / "artifacts" / "v2-chat-tables" +IDENTIFIER = "catalog_and_tagged_state_" * 8 +RECORD = '{"has_struct_tree":false,"is_tagged":false,"mark_info_state":"absent","struct_tree_state":"absent"}' +LONG_URL = "https://example.test/evidence/" + "source_record_" * 30 +TABLE = ( + "Native file evidence\n\n" + "| Location | Field | Source state | Exact value / supported record |\n" + "| --- | --- | --- | --- |\n" + f"| SC-C05.pdf; PDF catalog | {IDENTIFIER} | present_value | `{RECORD}` |\n" + f"| SC-C05.pdf; inspection | reported_issue | present_value | [Source record]({LONG_URL}) |\n" + f"| SC-C05.pdf; source | URL | present_value | {LONG_URL} |\n" + "| SC-C05.pdf; page 1 | citation | present_value | " + "(Source: SC-C05.pdf, Page: 1) [#table-document_1] |\n" +) +CITATIONS = [{ + "file_name": "SC-C05.pdf", + "document_id": "table-document", + "citation_id": "table-document_1", + "page_number": 1, +}] + + +def prepare(page, api, width, theme="light", preference="wide"): + page.set_viewport_size({"width": width, "height": 900}) + page.route("**/api/v2/orchestration/questions?*", lambda route: route.fulfill( + json={"questions": [], "csrf_token": "fixture-token"}, + )) + for path in ("v2/user-input/pending", "action-credentials/pending"): + page.route(f"**/api/{path}?*", lambda route: route.fulfill( + json={"requests": [], "csrf_token": "fixture-token"}, + )) + page.add_init_script( + f"localStorage.setItem('simplechat.v2.chat-width', '{preference}');" + ) + mount(page, api, messages=[ + message("plain", "A short assistant response."), + message("user", "A short user message.", role="user"), + message("table", TABLE, hybrid_citations=CITATIONS), + ]) + page.evaluate( + "(dark) => document.documentElement.classList.toggle('dark', dark)", + theme == "dark", + ) + frames(page) + + +def table_geometry(page, selector="#message-table"): + return page.locator(selector).evaluate( + """(message) => { + const table = message.querySelector('table'); + const bubble = table.closest('.glass-flat'); + const pane = message.closest('[data-tour="message-list"]'); + const wrapper = table.parentElement; + const box = element => { + const rect = element.getBoundingClientRect(); + return {left: rect.left, right: rect.right, width: rect.width}; + }; + const paneBox = box(pane); + const paneStyle = getComputedStyle(pane); + return { + table: box(table), bubble: box(bubble), wrapper: box(wrapper), + container: box(message.parentElement), pane: paneBox, + paneContentLeft: paneBox.left + parseFloat(paneStyle.paddingLeft), + paneContentRight: paneBox.right - parseFloat(paneStyle.paddingRight), + paneScroll: pane.scrollWidth - pane.clientWidth, + pageScroll: document.documentElement.scrollWidth - innerWidth, + tableScroll: wrapper.scrollWidth - wrapper.clientWidth, + cellOverflow: [...table.querySelectorAll('th,td')].map(cell => + cell.scrollWidth - cell.clientWidth), + narrowestCell: Math.min(...[...table.querySelectorAll('th,td')].map(cell => + cell.getBoundingClientRect().width)), + fontSize: getComputedStyle(table).fontSize, + }; + }""" + ) + + +def assert_contained(page, selector="#message-table", *, scrolls=False): + measured = table_geometry(page, selector) + assert measured["bubble"]["left"] >= measured["paneContentLeft"] - 1, measured + assert measured["bubble"]["right"] <= measured["paneContentRight"] + 1, measured + assert measured["wrapper"]["right"] <= measured["bubble"]["right"] - 15, measured + if scrolls: + assert measured["tableScroll"] > 1, measured + else: + assert measured["table"]["right"] <= measured["bubble"]["right"] - 15, measured + assert measured["tableScroll"] <= 1, measured + assert max(measured["cellOverflow"]) <= 1, measured + assert measured["narrowestCell"] >= 56, measured + assert measured["paneScroll"] <= 1 and measured["pageScroll"] <= 1, measured + assert measured["fontSize"] == "14px", measured + return measured + + +@pytest.mark.parametrize("width,theme,preference,scrolls", [ + (320, "light", "wide", True), + (393, "dark", "comfortable", False), + (768, "light", "comfortable", False), + (1280, "dark", "comfortable", False), + (1920, "light", "wide", False), +]) +def test_evidence_table_expands_and_wraps_inside_its_bubble(scroll_ui, width, theme, preference, scrolls): + page, api = scroll_ui + prepare(page, api, width, theme, preference) + measured = assert_contained(page, scrolls=scrolls) + assert measured["bubble"]["width"] == pytest.approx(measured["container"]["width"], abs=1) + table = page.locator("#message-table table") + expect(table.locator("tbody tr")).to_have_count(4) + expect(table.locator("code")).to_have_text(RECORD) + expect(table.get_by_role("link", name="Source record", exact=True)).to_have_attribute("href", LONG_URL) + expect(table.get_by_title("SC-C05.pdf — Page 1", exact=True)).to_be_visible() + assert page.locator("#message-plain .glass-flat").bounding_box()["width"] < measured["bubble"]["width"] + assert page.locator("#message-user .bg-accent").bounding_box()["width"] < measured["bubble"]["width"] + ARTIFACTS.mkdir(parents=True, exist_ok=True) + root(page).evaluate( + """pane => { + pane.scrollTop += pane.querySelector('#message-table').getBoundingClientRect().top + - pane.getBoundingClientRect().top; + }""" + ) + root(page).screenshot(path=str(ARTIFACTS / f"evidence-{width}-{theme}-{preference}.png")) + + +def test_text_and_code_wrap_without_scrolling_at_320_pixels(scroll_ui): + page, api = scroll_ui + prepare(page, api, 320) + # The citation chip has a non-wrapping label; ordinary cell text does not. + content = TABLE.split("\n| SC-C05.pdf; page 1", 1)[0] + page.evaluate( + "(content) => window.OrchHarness.stores.chat.useChatStore.setState(state => " + "({messages: state.messages.map(item => item.id === 'table' ? {...item, content} : item)}))", + content, + ) + expect(page.locator("#message-table tbody tr")).to_have_count(3) + assert_contained(page) + + +def test_resizing_preserves_table_content_and_normal_message_widths(scroll_ui): + page, api = scroll_ui + prepare(page, api, 1920) + plain = page.locator("#message-plain .glass-flat").bounding_box()["width"] + user = page.locator("#message-user .bg-accent").bounding_box()["width"] + for width in (393, 768, 1920): + page.set_viewport_size({"width": width, "height": 900}) + frames(page) + assert_contained(page) + expect(page.locator("#message-table table")).to_contain_text(IDENTIFIER) + expect(page.locator("#message-table table code")).to_have_text(RECORD) + assert page.locator("#message-plain .glass-flat").bounding_box()["width"] == pytest.approx(plain) + assert page.locator("#message-user .bg-accent").bounding_box()["width"] == pytest.approx(user) + + +def test_streaming_finalized_and_reloaded_tables_use_the_same_layout(scroll_ui): + page, api = scroll_ui + prepare(page, api, 1280) + start_stream(page) + push_content(page, "Native file evidence\n\n| Location | Field |") + push_content(page, TABLE) + expect(page.locator("[data-scroll-live-reply] table")).to_be_visible() + assert_contained(page, "[data-scroll-live-reply]") + complete(page, TABLE, message_id="finished-table") + expect(page.locator("[data-scroll-live-reply]")).to_have_count(0) + finished = assert_contained(page, "#message-finished-table") + mount(page, api, messages=[message("finished-table", TABLE)]) + reloaded = assert_contained(page, "#message-finished-table") + assert reloaded["bubble"]["width"] == pytest.approx(finished["bubble"]["width"]) + + +def test_unavoidably_wide_table_scrolls_locally_by_keyboard(scroll_ui): + page, api = scroll_ui + prepare(page, api, 393) + columns = 30 + content = ( + "| " + " | ".join(f"Column {index}" for index in range(columns)) + " |\n" + "| " + " | ".join("---" for _ in range(columns)) + " |\n" + "| " + " | ".join(f"Value {index}" for index in range(columns)) + " |\n" + ) + page.evaluate( + "(content) => window.OrchHarness.stores.chat.useChatStore.setState(state => " + "({messages: state.messages.map(item => item.id === 'table' ? {...item, content} : item)}))", + content, + ) + wrapper = page.locator("#message-table").get_by_role("region", name="Table", exact=True) + expect(wrapper).to_be_visible() + measured = table_geometry(page) + assert measured["tableScroll"] > 0, measured + assert measured["wrapper"]["right"] <= measured["bubble"]["right"] - 15, measured + assert measured["paneScroll"] <= 1 and measured["pageScroll"] <= 1, measured + wrapper.focus() + expect(wrapper).to_be_focused() + for _ in range(columns * 2): + wrapper.press("ArrowRight") + page.wait_for_function( + """() => { + const wrapper = document.querySelector('#message-table [role="region"]'); + return wrapper.scrollLeft > 0 && + wrapper.scrollWidth - wrapper.clientWidth - wrapper.scrollLeft <= 1; + }""" + ) + last_cell = wrapper.locator("tbody td").last.bounding_box() + bounds = wrapper.bounding_box() + assert last_cell["x"] + last_cell["width"] <= bounds["x"] + bounds["width"] + 1
    {renderTokens(children)}{renderTokens(children)}