diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index 837f9428aca..a0c6d6bcf11 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -263,7 +263,9 @@ describe("gateway update.run", () => { ); const res = await onceMessage(ws, (o) => o.type === "res" && o.id === id); expect(res.ok).toBe(true); - expect(updateMock).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(updateMock).toHaveBeenCalledOnce(); + }, FAST_WAIT_OPTS); } finally { process.off("SIGUSR1", sigusr1); } diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 27c6765308f..69e1b8fb2fe 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -83,6 +83,85 @@ color: var(--text); } +.sidebar-markdown-shell { + display: grid; + gap: 14px; + min-width: 0; +} + +.sidebar-markdown-shell__toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.sidebar-markdown-shell__intro { + min-width: 0; + display: grid; + gap: 6px; +} + +.sidebar-markdown-shell__eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.sidebar-markdown-shell__eyebrow svg { + width: 14px; + height: 14px; +} + +.sidebar-markdown-shell__hint { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.sidebar-markdown-reader { + --md-preview-serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + position: relative; + padding: 20px 18px 24px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, white 8%); + border-radius: calc(var(--radius-xl) + 2px); + background: + radial-gradient(circle at top right, rgba(255, 92, 92, 0.07), transparent 34%), + linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, white 4%), var(--card)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + var(--shadow-sm); + overflow: hidden; +} + +.sidebar-markdown-reader::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.03), transparent 26%), + linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 20%); +} + +.sidebar-markdown-reader > * { + position: relative; + z-index: 1; +} + +.sidebar-markdown-reader.sidebar-markdown { + font-size: 14.5px; + line-height: 1.72; +} + /* ── Headings ── */ .sidebar-markdown :where(h1) { @@ -93,6 +172,8 @@ padding-bottom: 0.35em; border-bottom: 1px solid var(--border); line-height: 1.25; + text-wrap: balance; + scroll-margin-top: 80px; } .sidebar-markdown :where(h2) { @@ -103,6 +184,8 @@ padding-bottom: 0.25em; border-bottom: 1px solid var(--border); line-height: 1.3; + text-wrap: balance; + scroll-margin-top: 80px; } .sidebar-markdown :where(h3) { @@ -111,6 +194,8 @@ letter-spacing: -0.01em; margin: 1.2em 0 0.4em; line-height: 1.35; + text-wrap: balance; + scroll-margin-top: 80px; } .sidebar-markdown :where(h4, h5, h6) { @@ -278,6 +363,24 @@ color: var(--text-strong); } +.sidebar-markdown-reader.sidebar-markdown :where(h1, h2) { + font-family: var(--md-preview-serif); + letter-spacing: -0.03em; +} + +.sidebar-markdown-reader.sidebar-markdown :where(h1) { + font-size: 1.95em; + line-height: 1.05; +} + +.sidebar-markdown-reader.sidebar-markdown :where(h2) { + font-size: 1.5em; +} + +.sidebar-markdown-reader.sidebar-markdown :where(p, ul, ol, pre, blockquote, table, details) { + margin-bottom: 0.95em; +} + /* ── Images ── */ .sidebar-markdown .markdown-inline-image { @@ -320,6 +423,23 @@ padding: 0 12px 10px; } +@media (max-width: 720px) { + .sidebar-markdown-shell__toolbar { + flex-direction: column; + align-items: stretch; + } + + .sidebar-markdown-reader { + padding: 18px 14px 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-markdown-reader { + transition: none; + } +} + /* Mobile: Full-screen modal */ @media (max-width: 768px) { .chat-split-container--open { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 5422887a59a..ee0b0fa90fe 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -3585,199 +3585,479 @@ td.data-table-key-col { } .md-preview-dialog { - width: min(980px, calc(100vw - 32px)); - max-width: none; - max-height: min(88vh, 960px); - padding: 0; border: none; - border-radius: calc(var(--radius-xl) + 4px); background: transparent; color: inherit; + padding: 0; + width: 100vw; + max-width: none; + height: 100vh; + max-height: none; overflow: hidden; } .md-preview-dialog::backdrop { - background: rgba(5, 8, 15, 0.72); - backdrop-filter: blur(10px); + background: + radial-gradient(circle at top, rgba(255, 92, 92, 0.12), transparent 32%), + rgba(5, 8, 15, 0.76); + backdrop-filter: blur(14px); } .md-preview-dialog__panel { + position: relative; + width: min(1040px, calc(100vw - 32px)); + min-height: min(76vh, 820px); + max-height: calc(100vh - 32px); + margin: 16px auto; display: flex; flex-direction: column; - min-height: min(70vh, 720px); - max-height: min(88vh, 960px); - border: 1px solid var(--border-strong); - border-radius: calc(var(--radius-xl) + 4px); - background: color-mix(in srgb, var(--panel) 92%, black 8%); - box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45); + border: 1px solid color-mix(in srgb, var(--border-strong) 84%, white 8%); + border-radius: calc(var(--radius-xl) + 6px); + background: + linear-gradient(180deg, color-mix(in srgb, var(--panel) 94%, black 6%), var(--panel)); + box-shadow: + 0 32px 96px rgba(0, 0, 0, 0.48), + 0 1px 0 rgba(255, 255, 255, 0.04) inset; overflow: hidden; + animation: scale-in 0.22s var(--ease-out); + transition: + width var(--duration-normal) var(--ease-out), + max-height var(--duration-normal) var(--ease-out), + margin var(--duration-normal) var(--ease-out); } .md-preview-dialog__header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - gap: 12px; - padding: 14px 18px; + gap: 18px; + padding: 20px 22px 16px; border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--panel) 88%, black 12%); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--panel) 90%, black 10%), + color-mix(in srgb, var(--panel) 96%, transparent 4%) + ); +} + +.md-preview-dialog__header-main { + min-width: 0; + display: grid; + gap: 10px; +} + +.md-preview-dialog__eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); +} + +.md-preview-dialog__eyebrow svg { + width: 14px; + height: 14px; +} + +.md-preview-dialog__title-wrap { + min-width: 0; + display: grid; + gap: 6px; } .md-preview-dialog__title { min-width: 0; - font-size: 14px; - font-weight: 600; - color: var(--text); - white-space: nowrap; + font-size: clamp(1.2rem, 1rem + 0.6vw, 1.75rem); + line-height: 1.08; + font-weight: 650; + letter-spacing: -0.03em; + color: var(--text-strong); + text-wrap: balance; + overflow-wrap: anywhere; +} + +.md-preview-dialog__path { + min-width: 0; + font-size: 12px; + color: var(--muted); overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} + +.md-preview-dialog__actions { + display: flex; + align-items: center; + gap: 7px; + flex-shrink: 0; +} + +.md-preview-icon-btn { + width: 36px; + height: 36px; + padding: 0; + border-radius: 12px; + border-color: color-mix(in srgb, var(--border) 78%, white 10%); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-elevated) 92%, white 8%), + color-mix(in srgb, var(--bg-elevated) 88%, black 12%) + ); + color: color-mix(in srgb, var(--text) 86%, var(--muted)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + 0 8px 20px rgba(0, 0, 0, 0.18); +} + +.md-preview-icon-btn:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--border-strong) 78%, white 14%); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-hover) 88%, white 10%), + color-mix(in srgb, var(--bg-elevated) 84%, black 16%) + ); + color: var(--text-strong); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 10px 24px rgba(0, 0, 0, 0.24); +} + +.md-preview-icon-btn[aria-pressed="true"] { + border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); + color: var(--text-strong); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent), + 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.md-preview-icon-btn > span { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.md-preview-icon-btn svg { + width: 16px; + height: 16px; } .md-preview-dialog__body { flex: 1; overflow: auto; - padding: clamp(20px, 3vw, 32px); + overscroll-behavior: contain; + padding: clamp(18px, 3vw, 28px); + background: + radial-gradient(circle at top right, rgba(255, 92, 92, 0.055), transparent 32%), + radial-gradient(circle at bottom left, rgba(20, 184, 166, 0.045), transparent 28%), + linear-gradient( + 180deg, + color-mix(in srgb, var(--panel) 84%, black 16%), + color-mix(in srgb, var(--bg) 88%, black 12%) + ); } -.md-preview-dialog__body.sidebar-markdown { +.md-preview-dialog__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 22px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 88%, white 6%); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--panel) 96%, transparent 4%), + color-mix(in srgb, var(--panel) 92%, black 8%) + ); +} + +.md-preview-dialog__chip { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + min-height: 32px; + padding: 6px 12px; + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, var(--border) 78%, white 10%); + background: color-mix(in srgb, var(--secondary) 72%, transparent); + color: var(--muted); + font-size: 12px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.md-preview-dialog__chip strong { + font-weight: 600; + color: var(--text-strong); +} + +.md-preview-dialog__chip.is-dirty { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: color-mix(in srgb, var(--accent-subtle) 72%, var(--secondary)); +} + +.md-preview-dialog__chip.is-missing { + border-color: color-mix(in srgb, var(--warn) 42%, var(--border)); + background: color-mix(in srgb, var(--warn-subtle) 78%, var(--secondary)); +} + +.md-preview-dialog__chip.is-synced { + border-color: color-mix(in srgb, var(--accent-2) 36%, var(--border)); + background: color-mix(in srgb, var(--accent-2-subtle) 72%, var(--secondary)); +} + +.md-preview-dialog__reader { + --md-preview-serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + --md-preview-document-bg: color-mix(in srgb, var(--bg) 92%, var(--panel) 8%); + --cm-bg: transparent; + --cm-border: color-mix(in srgb, var(--border) 72%, white 10%); + --cm-code-bg: color-mix(in srgb, var(--bg-elevated) 72%, black 28%); + --cm-inline-code-bg: color-mix(in srgb, var(--secondary) 58%, transparent); + position: relative; + width: min(100%, 82ch); + margin: 0 auto; + padding: clamp(26px, 4vw, 56px); + border: 1px solid color-mix(in srgb, var(--border-strong) 70%, white 10%); + border-radius: calc(var(--radius-xl) + 2px); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--md-preview-document-bg) 94%, white 6%), + var(--md-preview-document-bg) + ); + box-shadow: + 0 22px 54px rgba(0, 0, 0, 0.32), + inset 0 1px 0 rgba(255, 255, 255, 0.035); +} + +.md-preview-dialog__reader::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 24%), + linear-gradient(180deg, rgba(255, 255, 255, 0.01), transparent 18%); +} + +.md-preview-dialog__reader > * { + position: relative; + z-index: 1; +} + +.md-preview-dialog__reader .cm-preview { + background: transparent; +} + +.md-preview-dialog__reader.sidebar-markdown { font-size: 15px; - line-height: 1.7; + line-height: 1.74; color: var(--text); } -.md-preview-dialog__body.sidebar-markdown > * { - width: min(100%, 92ch); - max-width: 100%; - margin-left: 0; - margin-right: 0; -} - -.md-preview-dialog__body.sidebar-markdown > :first-child { +.md-preview-dialog__reader.sidebar-markdown > :first-child { margin-top: 0; } -.md-preview-dialog__body.sidebar-markdown > :last-child { +.md-preview-dialog__reader.sidebar-markdown > :last-child { margin-bottom: 0; } -.md-preview-dialog__body.sidebar-markdown :is(h1, h2, h3, h4, h5, h6) { +.md-preview-dialog__reader.sidebar-markdown :is(h1, h2, h3, h4, h5, h6) { margin: 0 0 0.9em; - line-height: 1.18; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--text); + line-height: 1.08; + font-weight: 650; + letter-spacing: -0.03em; + color: var(--text-strong); + text-wrap: balance; } -.md-preview-dialog__body.sidebar-markdown h1 { - font-size: clamp(2rem, 4vw, 2.6rem); +.md-preview-dialog__reader.sidebar-markdown h1 { + font-family: var(--md-preview-serif); + font-size: clamp(2.2rem, 4.4vw, 3.35rem); + line-height: 0.98; } -.md-preview-dialog__body.sidebar-markdown h2 { - margin-top: 2.2rem; - padding-top: 1.2rem; - font-size: clamp(1.4rem, 2.4vw, 1.85rem); - border-top: 1px solid var(--border); +.md-preview-dialog__reader.sidebar-markdown h2 { + margin-top: 2.4rem; + padding-top: 1.3rem; + border-top: 1px solid color-mix(in srgb, var(--border) 82%, white 12%); + font-family: var(--md-preview-serif); + font-size: clamp(1.45rem, 2.1vw, 2rem); } -.md-preview-dialog__body.sidebar-markdown h3 { - margin-top: 1.8rem; - font-size: clamp(1.16rem, 1.8vw, 1.35rem); +.md-preview-dialog__reader.sidebar-markdown h3 { + margin-top: 1.9rem; + font-size: clamp(1.18rem, 1.4vw, 1.42rem); } -.md-preview-dialog__body.sidebar-markdown :is(p, ul, ol, blockquote, pre, table, hr) { +.md-preview-dialog__reader.sidebar-markdown :is(p, ul, ol, blockquote, pre, table, hr, details) { margin-top: 0; - margin-bottom: 1rem; + margin-bottom: 1.05rem; } -.md-preview-dialog__body.sidebar-markdown :is(ul, ol) { - padding-left: 1.1rem; +.md-preview-dialog__reader.sidebar-markdown :is(ul, ol) { + padding-left: 1.2rem; } -.md-preview-dialog__body.sidebar-markdown :is(ul ul, ul ol, ol ul, ol ol) { - margin-top: 0.45rem; - margin-bottom: 0.45rem; - padding-left: 0.95rem; +.md-preview-dialog__reader.sidebar-markdown :is(ul ul, ul ol, ol ul, ol ol) { + margin-top: 0.4rem; + margin-bottom: 0.4rem; } -.md-preview-dialog__body.sidebar-markdown li { - padding-left: 0.1rem; +.md-preview-dialog__reader.sidebar-markdown li + li { + margin-top: 0.32rem; } -.md-preview-dialog__body.sidebar-markdown li + li { - margin-top: 0.35rem; -} - -.md-preview-dialog__body.sidebar-markdown blockquote { - padding: 0.85rem 1rem; - border-left: 3px solid var(--accent); +.md-preview-dialog__reader.sidebar-markdown blockquote { + padding: 0.95rem 1.1rem; + border-left: 3px solid color-mix(in srgb, var(--accent) 85%, white 15%); border-radius: 0 var(--radius-md) var(--radius-md) 0; - background: color-mix(in srgb, var(--secondary) 60%, transparent); - color: var(--text-soft, var(--text)); + background: color-mix(in srgb, var(--secondary) 64%, transparent); + color: color-mix(in srgb, var(--text) 82%, var(--muted)); } -.md-preview-dialog__body.sidebar-markdown pre { - padding: 14px 16px; - border: 1px solid var(--border); +.md-preview-dialog__reader.sidebar-markdown pre { + padding: 16px 18px; + border: 1px solid color-mix(in srgb, var(--border) 88%, white 8%); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--bg-elevated) 84%, black 16%); } -.md-preview-dialog__body.sidebar-markdown :not(pre) > code { +.md-preview-dialog__reader.sidebar-markdown :not(pre) > code { padding: 0.16rem 0.42rem; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 82%, white 8%); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--secondary) 62%, transparent); - font-size: 0.92em; + font-size: 0.9em; } -.md-preview-dialog__body.sidebar-markdown hr { +.md-preview-dialog__reader.sidebar-markdown hr { border: 0; - border-top: 1px solid var(--border); + border-top: 1px solid color-mix(in srgb, var(--border) 82%, white 10%); } -.md-preview-dialog__body.sidebar-markdown table { +.md-preview-dialog__reader.sidebar-markdown table { width: 100%; border-collapse: collapse; border-spacing: 0; overflow: hidden; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 82%, white 10%); border-radius: var(--radius-lg); } -.md-preview-dialog__body.sidebar-markdown :is(th, td) { +.md-preview-dialog__reader.sidebar-markdown :is(th, td) { padding: 10px 12px; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, white 8%); text-align: left; vertical-align: top; } -.md-preview-dialog__body.sidebar-markdown th { +.md-preview-dialog__reader.sidebar-markdown th { background: color-mix(in srgb, var(--secondary) 56%, transparent); font-weight: 600; } -.md-preview-dialog__body.sidebar-markdown tr:last-child td { +.md-preview-dialog__reader.sidebar-markdown tr:last-child td { border-bottom: none; } +.md-preview-dialog__panel.fullscreen { + width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + margin: 10px auto; +} + +.md-preview-dialog__panel.fullscreen .md-preview-dialog__header { + align-items: center; + justify-content: flex-end; + gap: 0; + padding: 10px 12px; +} + +.md-preview-dialog__panel.fullscreen .md-preview-dialog__header-main { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.md-preview-dialog__panel.fullscreen .md-preview-dialog__actions { + margin-left: auto; +} + +.md-preview-dialog__panel.fullscreen .md-preview-dialog__meta { + display: none; +} + +.md-preview-dialog__panel.fullscreen .md-preview-dialog__body { + padding: clamp(10px, 1.5vw, 18px); +} + +.md-preview-dialog__panel.fullscreen .md-preview-dialog__reader { + width: min(100%, 96ch); + padding: clamp(24px, 3vw, 52px); +} + @media (max-width: 720px) { .md-preview-dialog { - width: min(calc(100vw - 12px), 100vw); - max-height: calc(100vh - 12px); + width: 100vw; + height: 100vh; } .md-preview-dialog__panel { + width: calc(100vw - 10px); min-height: calc(100vh - 12px); max-height: calc(100vh - 12px); + margin: 6px auto; border-radius: var(--radius-xl); } .md-preview-dialog__header { - padding: 12px 14px; + flex-direction: column; + align-items: stretch; + padding: 16px 16px 14px; + } + + .md-preview-dialog__actions { + justify-content: flex-end; + flex-wrap: wrap; + } + + .md-preview-dialog__meta { + padding: 0 16px 14px; } .md-preview-dialog__body { - padding: 16px; + padding: 14px; + } + + .md-preview-dialog__reader { + width: 100%; + padding: 20px 16px 22px; + } +} + +@media (prefers-reduced-motion: reduce) { + .md-preview-dialog::backdrop { + backdrop-filter: none; + } + + .md-preview-dialog__panel { + animation: none; + transition: none; } } @@ -4983,82 +5263,6 @@ details[open] > .ov-expandable-toggle::after { transform: rotate(-90deg); } -/* =========================================== - Markdown Preview Dialog - =========================================== */ - -.md-preview-dialog { - border: none; - background: transparent; - padding: 0; - max-width: none; - max-height: none; - width: 100vw; - height: 100vh; - overflow: hidden; -} - -.md-preview-dialog::backdrop { - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(6px); -} - -.md-preview-dialog__panel { - width: min(780px, calc(100vw - 48px)); - max-height: calc(100vh - 64px); - margin: 32px auto; - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); - display: flex; - flex-direction: column; - overflow: hidden; - animation: scale-in 0.2s var(--ease-out); - transition: - width 0.2s var(--ease-out), - max-height 0.2s var(--ease-out), - margin 0.2s var(--ease-out); -} - -.md-preview-dialog__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px 20px; - border-bottom: 1px solid var(--border); - background: var(--card); - flex-shrink: 0; -} - -.md-preview-dialog__title { - font-size: 13px; - font-weight: 600; - color: var(--text); - letter-spacing: -0.01em; -} - -.md-preview-dialog__actions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.md-preview-dialog__body { - flex: 1; - overflow-y: auto; - padding: 16px 20px 24px; -} - -.md-preview-dialog__panel.fullscreen { - width: calc(100vw - 32px); - max-width: none; - max-height: calc(100vh - 32px); - margin: 16px auto; -} - .md-preview-expand-btn .when-fullscreen { display: none; } @@ -5071,23 +5275,6 @@ details[open] > .ov-expandable-toggle::after { display: inline; } -@media (max-width: 640px) { - .md-preview-dialog__panel { - width: calc(100vw - 16px); - max-height: calc(100vh - 32px); - margin: 16px auto; - border-radius: var(--radius-md); - } - - .md-preview-dialog__header { - padding: 12px 16px; - } - - .md-preview-dialog__body { - padding: 14px 12px 20px; - } -} - @media (max-width: 600px) { .ov-cards { grid-template-columns: repeat(2, 1fr); diff --git a/ui/src/styles/markdown-preview.test.ts b/ui/src/styles/markdown-preview.test.ts new file mode 100644 index 00000000000..183744f3d14 --- /dev/null +++ b/ui/src/styles/markdown-preview.test.ts @@ -0,0 +1,45 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("markdown preview styles", () => { + it("keeps the preview dialog canvas unified", async () => { + const css = await readFile("ui/src/styles/components.css", "utf8"); + + expect(css).toContain(".md-preview-dialog__header-main"); + expect(css).toContain(".md-preview-dialog__meta"); + expect(css).toContain("--cm-bg: transparent;"); + expect(css).toContain(".md-preview-dialog__reader .cm-preview"); + expect(css).not.toContain("width: min(780px, calc(100vw - 48px));"); + expect(css).not.toContain("background: rgba(0, 0, 0, 0.65);"); + expect(css).not.toContain("color-mix(in srgb, var(--card) 94%, white 6%)"); + }); + + it("keeps expanded previews focused on header controls and reading space", async () => { + const css = await readFile("ui/src/styles/components.css", "utf8"); + + expect(css).toContain(".md-preview-dialog__panel.fullscreen .md-preview-dialog__header-main"); + expect(css).toContain("clip-path: inset(50%);"); + expect(css).toMatch( + /\.md-preview-dialog__panel\.fullscreen\s+\.md-preview-dialog__meta\s*\{[^}]*display:\s*none;/, + ); + expect(css).toContain(".md-preview-dialog__panel.fullscreen .md-preview-dialog__body"); + expect(css).toContain("width: min(100%, 96ch);"); + }); + + it("styles preview header controls as compact icon buttons", async () => { + const css = await readFile("ui/src/styles/components.css", "utf8"); + + expect(css).toContain(".md-preview-icon-btn"); + expect(css).toContain("width: 36px;"); + expect(css).toContain("height: 36px;"); + expect(css).toContain('.md-preview-icon-btn[aria-pressed="true"]'); + }); + + it("keeps the sidebar reader shell in sidebar.css", async () => { + const css = await readFile("ui/src/styles/chat/sidebar.css", "utf8"); + + expect(css).toContain(".sidebar-markdown-shell__toolbar"); + expect(css).toContain(".sidebar-markdown-reader"); + expect(css).toContain(".sidebar-markdown-shell__hint"); + }); +}); diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index d142368cc7f..f1844a454dd 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -303,10 +303,14 @@ export function renderToolPreview( `; } -export function buildSidebarContent(value: string): SidebarContent { +export function buildSidebarContent( + value: string, + options?: { rawText?: string | null }, +): SidebarContent { return { kind: "markdown", content: value, + ...(options?.rawText ? { rawText: options.rawText } : {}), }; } diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 9794ca8dd6a..f637d4726a5 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -528,5 +528,7 @@ describe("renderMarkdownSidebar", () => { ); expect(container.querySelector(".sidebar-markdown strong")?.textContent).toBe("world"); + expect(container.textContent).toContain("Rendered Markdown"); + expect(container.textContent).toContain("View Raw Text"); }); }); diff --git a/ui/src/ui/sidebar-content.ts b/ui/src/ui/sidebar-content.ts index ed90de26ae0..adad3a23f0b 100644 --- a/ui/src/ui/sidebar-content.ts +++ b/ui/src/ui/sidebar-content.ts @@ -1,6 +1,7 @@ export type MarkdownSidebarContent = { kind: "markdown"; content: string; + rawText?: string | null; }; export type CanvasSidebarContent = { diff --git a/ui/src/ui/views/agents-panels-status-files.ts b/ui/src/ui/views/agents-panels-status-files.ts index b1d9f05b406..db7fd5caaee 100644 --- a/ui/src/ui/views/agents-panels-status-files.ts +++ b/ui/src/ui/views/agents-panels-status-files.ts @@ -19,10 +19,72 @@ import type { CronJob, CronStatus, } from "../types.ts"; -import { type AgentContext } from "./agents-utils.ts"; +import { formatBytes, type AgentContext } from "./agents-utils.ts"; import type { AgentsPanel } from "./agents.types.ts"; import { resolveChannelExtras as resolveChannelExtrasFromConfig } from "./channel-config-extras.ts"; +function countWords(text: string) { + const normalized = text.trim(); + return normalized ? normalized.split(/\s+/).length : 0; +} + +function countLines(text: string) { + return text.length === 0 ? 0 : text.split(/\r?\n/).length; +} + +function estimateReadingTimeLabel(wordCount: number) { + if (wordCount <= 0) { + return "Empty draft"; + } + return `${Math.max(1, Math.round(wordCount / 220))} min read`; +} + +function getExtensionLabel(fileName: string) { + const ext = fileName.split(".").pop()?.trim().toLowerCase(); + if (ext === "md" || ext === "markdown") { + return "Markdown Preview"; + } + return ext ? `${ext.toUpperCase()} Preview` : "Preview"; +} + +function formatWorkspaceRelativePath(filePath: string, workspace: string | null | undefined) { + const normalizedPath = filePath.trim(); + const normalizedWorkspace = workspace?.trim(); + if (!normalizedPath) { + return ""; + } + if (normalizedWorkspace && normalizedPath === normalizedWorkspace) { + return "."; + } + if (normalizedWorkspace && normalizedPath.startsWith(`${normalizedWorkspace}/`)) { + return normalizedPath.slice(normalizedWorkspace.length + 1) || "."; + } + const pathParts = normalizedPath.split(/[\\/]+/); + for (let index = pathParts.length - 1; index >= 0; index -= 1) { + const pathPart = pathParts[index]; + if (pathPart) { + return pathPart; + } + } + return normalizedPath; +} + +function toDomId(value: string) { + const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return normalized.replace(/^-+|-+$/g, "") || "preview"; +} + +function setPreviewExpandButtonState(button: Element | null | undefined, isFullscreen: boolean) { + if (!(button instanceof HTMLElement)) { + return; + } + const label = isFullscreen ? "Collapse preview" : "Expand preview"; + button.classList.toggle("is-fullscreen", isFullscreen); + button.setAttribute("aria-pressed", String(isFullscreen)); + button.setAttribute("aria-label", label); + button.setAttribute("title", label); +} + function renderAgentContextCard( context: AgentContext, subtitle: string, @@ -365,6 +427,33 @@ export function renderAgentFiles(params: { const baseContent = active ? (params.agentFileContents[active] ?? "") : ""; const draft = active ? (params.agentFileDrafts[active] ?? baseContent) : ""; const isDirty = active ? draft !== baseContent : false; + const previewHtml = activeEntry + ? applyPreviewTheme(marked.parse(draft, { gfm: true, breaks: true }) as string, { + sanitize: (h: string) => DOMPurify.sanitize(h), + }) + : ""; + const draftByteSize = formatBytes(new TextEncoder().encode(draft).length); + const draftWordCount = countWords(draft); + const draftLineCount = countLines(draft); + const activePathLabel = activeEntry + ? formatWorkspaceRelativePath(activeEntry.path, list?.workspace) + : ""; + const previewTitleId = activeEntry ? `agent-file-preview-title-${toDomId(activeEntry.name)}` : ""; + const previewStatusLabel = activeEntry?.missing + ? "Will Create on Save" + : isDirty + ? "Live Draft Preview" + : "Saved Preview"; + const previewStatusClass = activeEntry?.missing + ? "is-missing" + : isDirty + ? "is-dirty" + : "is-synced"; + const previewUpdatedLabel = activeEntry?.updatedAtMs + ? `Updated ${formatRelativeTimestamp(activeEntry.updatedAtMs)}` + : activeEntry?.missing + ? "Not Created Yet" + : "Updated Unknown"; return html`
@@ -476,6 +565,7 @@ export function renderAgentFiles(params: { { const dialog = e.currentTarget as HTMLDialogElement; if (e.target === dialog) { @@ -487,15 +577,39 @@ export function renderAgentFiles(params: { dialog .querySelector(".md-preview-dialog__panel") ?.classList.remove("fullscreen"); + setPreviewExpandButtonState( + dialog.querySelector(".md-preview-expand-btn"), + false, + ); }} >
-
${activeEntry.name}
+
+
+ ${icons.scrollText} + ${getExtensionLabel(activeEntry.name)} +
+
+
+ ${activeEntry.name} +
+
+ ${activePathLabel} +
+
+
+
+
+ ${previewStatusLabel} +
+
+ ${estimateReadingTimeLabel(draftWordCount)} + ${draftWordCount} words +
+
+ ${draftLineCount} + lines +
+
+ ${draftByteSize} + ${previewUpdatedLabel} +
+
- ${unsafeHTML( - applyPreviewTheme( - marked.parse(draft, { gfm: true, breaks: true }) as string, - { sanitize: (h: string) => DOMPurify.sanitize(h) }, - ), - )} +
diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index c48fb3bda09..1024f19937d 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -1,5 +1,6 @@ import { render } from "lit"; import { describe, expect, it } from "vitest"; +import { renderAgentFiles } from "./agents-panels-status-files.ts"; import { renderAgents, type AgentsProps } from "./agents.ts"; function createSkill() { @@ -177,3 +178,161 @@ describe("renderAgents", () => { expect(skillsTab?.textContent?.trim()).toContain("1"); }); }); + +describe("renderAgentFiles", () => { + it("renders the upgraded markdown preview structure with file metadata", () => { + const container = document.createElement("div"); + + render( + renderAgentFiles({ + agentId: "alpha", + agentFilesList: { + agentId: "alpha", + workspace: "/tmp/workspace", + files: [ + { + name: "USER.md", + path: "/tmp/workspace/USER.md", + missing: false, + size: 128, + updatedAtMs: 1_700_000_000_000, + }, + ], + }, + agentFilesLoading: false, + agentFilesError: null, + agentFileActive: "USER.md", + agentFileContents: { + "USER.md": "# User Profile\n\nHello world", + }, + agentFileDrafts: { + "USER.md": "# User Profile\n\nHello world", + }, + agentFileSaving: false, + onLoadFiles: () => undefined, + onSelectFile: () => undefined, + onFileDraftChange: () => undefined, + onFileReset: () => undefined, + onFileSave: () => undefined, + }), + container, + ); + + expect(container.querySelector(".md-preview-dialog__reader.sidebar-markdown")).not.toBeNull(); + expect(container.querySelector(".md-preview-dialog__path")?.textContent?.trim()).toBe( + "USER.md", + ); + expect(container.querySelector(".md-preview-dialog__chip strong")?.textContent).toBe( + "Saved Preview", + ); + expect(container.textContent).toContain("Markdown Preview"); + }); + + it("renders preview header controls as icon-only buttons with accessible labels", () => { + const container = document.createElement("div"); + + render( + renderAgentFiles({ + agentId: "alpha", + agentFilesList: { + agentId: "alpha", + workspace: "/tmp/workspace", + files: [ + { + name: "USER.md", + path: "/tmp/workspace/USER.md", + missing: false, + size: 128, + updatedAtMs: 1_700_000_000_000, + }, + ], + }, + agentFilesLoading: false, + agentFilesError: null, + agentFileActive: "USER.md", + agentFileContents: { + "USER.md": "# User Profile\n\nHello world", + }, + agentFileDrafts: { + "USER.md": "# User Profile\n\nHello world", + }, + agentFileSaving: false, + onLoadFiles: () => undefined, + onSelectFile: () => undefined, + onFileDraftChange: () => undefined, + onFileReset: () => undefined, + onFileSave: () => undefined, + }), + container, + ); + + const actions = Array.from( + container.querySelectorAll(".md-preview-dialog__actions button"), + ); + + expect(actions).toHaveLength(3); + expect(actions.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Expand preview", + "Edit file", + "Close preview", + ]); + expect(actions.map((button) => button.textContent?.trim())).toEqual(["", "", ""]); + }); + + it("resets the expanded preview button state when the dialog closes", () => { + const container = document.createElement("div"); + + render( + renderAgentFiles({ + agentId: "alpha", + agentFilesList: { + agentId: "alpha", + workspace: "/tmp/workspace", + files: [ + { + name: "USER.md", + path: "/tmp/workspace/USER.md", + missing: false, + size: 128, + updatedAtMs: 1_700_000_000_000, + }, + ], + }, + agentFilesLoading: false, + agentFilesError: null, + agentFileActive: "USER.md", + agentFileContents: { + "USER.md": "# User Profile\n\nHello world", + }, + agentFileDrafts: { + "USER.md": "# User Profile\n\nHello world", + }, + agentFileSaving: false, + onLoadFiles: () => undefined, + onSelectFile: () => undefined, + onFileDraftChange: () => undefined, + onFileReset: () => undefined, + onFileSave: () => undefined, + }), + container, + ); + + const dialog = container.querySelector(".md-preview-dialog"); + const panel = container.querySelector(".md-preview-dialog__panel"); + const expandButton = container.querySelector(".md-preview-expand-btn"); + + expandButton?.click(); + + expect(panel?.classList.contains("fullscreen")).toBe(true); + expect(expandButton?.classList.contains("is-fullscreen")).toBe(true); + expect(expandButton?.getAttribute("aria-pressed")).toBe("true"); + expect(expandButton?.getAttribute("aria-label")).toBe("Collapse preview"); + + dialog?.dispatchEvent(new Event("close")); + + expect(panel?.classList.contains("fullscreen")).toBe(false); + expect(expandButton?.classList.contains("is-fullscreen")).toBe(false); + expect(expandButton?.getAttribute("aria-pressed")).toBe("false"); + expect(expandButton?.getAttribute("aria-label")).toBe("Expand preview"); + }); +}); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index a1acbaf183d..e072a68cb79 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -111,6 +111,42 @@ describe("renderChat", () => { cleanupChatModuleState(); }); + it("keeps markdown raw text toggles idempotent", () => { + const container = document.createElement("div"); + const onOpenSidebar = vi.fn(); + const rawMarkdown = "```ts\nconst value = 1;\n```"; + + render( + renderChat( + createProps({ + sidebarOpen: true, + sidebarContent: { + kind: "markdown", + content: `\`\`\`\n${rawMarkdown}\n\`\`\``, + rawText: rawMarkdown, + }, + stream: null, + streamStartedAt: null, + onCloseSidebar: () => undefined, + onOpenSidebar, + }), + ), + container, + ); + + const rawButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("View Raw Text"), + ); + rawButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(rawButton).not.toBeNull(); + expect(onOpenSidebar).toHaveBeenCalledWith({ + kind: "markdown", + content: `\`\`\`\n${rawMarkdown}\n\`\`\``, + rawText: rawMarkdown, + }); + }); + it("renders configured assistant text avatars in transcript groups", () => { const container = document.createElement("div"); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index cd21b098086..d248c8fa65a 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -141,6 +141,11 @@ function getPinnedMessages(sessionKey: string): PinnedMessages { ); } +function toPlainTextCodeFence(value: string, language = ""): string { + const fenceHeader = language ? `\`\`\`${language}` : "```"; + return `${fenceHeader}\n${value}\n\`\`\``; +} + function getDeletedMessages(sessionKey: string): DeletedMessages { return getOrCreateSessionCacheValue( deletedMessagesMap, @@ -1129,14 +1134,17 @@ export function renderChat(props: ChatProps) { return; } if (props.sidebarContent.kind === "markdown") { + const rawText = props.sidebarContent.rawText ?? props.sidebarContent.content; props.onOpenSidebar( - buildSidebarContent(`\`\`\`\n${props.sidebarContent.content}\n\`\`\``), + buildSidebarContent(toPlainTextCodeFence(rawText), { rawText }), ); return; } if (props.sidebarContent.rawText?.trim()) { props.onOpenSidebar( - buildSidebarContent(`\`\`\`json\n${props.sidebarContent.rawText}\n\`\`\``), + buildSidebarContent( + toPlainTextCodeFence(props.sidebarContent.rawText, "json"), + ), ); } }, diff --git a/ui/src/ui/views/markdown-sidebar.ts b/ui/src/ui/views/markdown-sidebar.ts index a91bcc9dd45..5c58311ddab 100644 --- a/ui/src/ui/views/markdown-sidebar.ts +++ b/ui/src/ui/views/markdown-sidebar.ts @@ -29,15 +29,32 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {