diff --git a/documents/ui_refactor/ui_refactor_manual_sampling_and_acceptance.md b/documents/ui_refactor/ui_refactor_manual_sampling_and_acceptance.md index e5ae7c5..2f2f362 100644 --- a/documents/ui_refactor/ui_refactor_manual_sampling_and_acceptance.md +++ b/documents/ui_refactor/ui_refactor_manual_sampling_and_acceptance.md @@ -85,6 +85,11 @@ Threads child flow (mandatory): 39. Threads export mode selector baseline: batch export panel shows `Full` / `Compact` / `Summary` above the Data-style `JSON` / `TXT` / `MD` rows, with `Full` selected by default and selector density matching the tray surface. 40. Threads compressed export resilience baseline: `Compact` and `Summary` first try the current LLM settings path, but export must still succeed through deterministic local fallback when the LLM path is unavailable or returns unusable output; post-export feedback must surface when fallback happened. 41. Threads export architecture baseline: the page must keep using the batch action tray flow and must not regress to the legacy modal-style `ExportDialog`. +42. Reader sidecar hierarchy baseline: `Sources`, `Attachments`, and `Artifacts` render as compact collapsed disclosure rows, remain visually subordinate to message body text, and do not read like primary content cards. +43. Reader sidecar spacing baseline: sidecar shells are inset from the Reader turn boundary and sidepanel outer frame; disclosure borders must not visually merge with the turn divider or page frame. +44. Gemini upload dedupe baseline: a single uploaded user turn with attachments renders exactly one `YOU` message row; attachment presence must not create a duplicate user turn. +45. Reader sidecar capsule baseline: collapsed `Attachment` / `Source` / `Artifact` renders as a single-line utility capsule aligned with the `Expand` / `Collapse` tool language; no second-line summary copy is shown in collapsed state. +46. Reader sidecar tray baseline: expanded sidecar content drops into a separate inset tray below the capsule, and the last visible tray edge remains visually separated from the Reader turn divider. --- diff --git a/documents/ui_refactor/v1_4_ui_refactor_engineering_spec.md b/documents/ui_refactor/v1_4_ui_refactor_engineering_spec.md index 7ebb7ca..b7b1751 100644 --- a/documents/ui_refactor/v1_4_ui_refactor_engineering_spec.md +++ b/documents/ui_refactor/v1_4_ui_refactor_engineering_spec.md @@ -103,6 +103,16 @@ Each level must map to explicit design tokens (background, border, shadow, text - Secondary actions: grouped and visually subordinate. - Destructive actions: isolated with explicit confirmation guard. +## 4.5.1 Reader sidecar hierarchy +- Reader message body remains the primary reading surface; `Sources`, `Attachments`, and `Artifacts` are secondary sidecar surfaces. +- Reader sidecars must render as single-line compact disclosure capsules, collapsed by default, instead of heavy card-like panels or two-line summary blocks. +- Reader sidecars share the same tool-surface language as the inline `Expand` / `Collapse` control: compact pill summary, muted emphasis, and utility-level density. +- Reader sidecar shells must be visually inset from the message-turn boundary and must not collapse into the sidepanel outer frame or turn divider line. +- Expanded sidecar content must drop into a separate inset tray below the capsule; do not keep the tray inside one shared bordered shell with the summary row. +- Reader sidecar typography must remain subordinate to message body typography; sidecar title/summary copy must not visually outrank the message text. +- `Attachments` is the lightest sidecar tier. Attachment items render as compact index rows only and must not use preview-card styling or image-heavy emphasis. +- Attachment-only messages still keep a minimal body anchor, but the attachment disclosure must not become the dominant headline block for the turn. + ## 4.6 Settings density and grouping - Settings is grouped into `Personalisation`, `System`, and `Support`. - `Appearance` and system controls remain disclosure-based. diff --git a/frontend/src/lib/core/parser/gemini/GeminiParser.ts b/frontend/src/lib/core/parser/gemini/GeminiParser.ts index 1823f08..d4e5a58 100644 --- a/frontend/src/lib/core/parser/gemini/GeminiParser.ts +++ b/frontend/src/lib/core/parser/gemini/GeminiParser.ts @@ -863,14 +863,24 @@ export class GeminiParser implements IParser { const deduped: ParsedMessage[] = []; for (const message of messages) { - const signature = this.buildMessageSignature(message); - const isDuplicate = deduped - .slice(Math.max(0, deduped.length - 2)) - .some((existing) => { - return this.buildMessageSignature(existing) === signature; - }); + const mergeSignature = this.buildMergeSignature(message); + let merged = false; + + for (let index = deduped.length - 1; index >= Math.max(0, deduped.length - 3); index -= 1) { + const existing = deduped[index]; + if (existing.role !== message.role) { + break; + } + if (this.buildMergeSignature(existing) !== mergeSignature) { + continue; + } + + deduped[index] = this.mergeParsedMessages(existing, message); + merged = true; + break; + } - if (!isDuplicate) { + if (!merged) { deduped.push(message); } } @@ -878,6 +888,136 @@ export class GeminiParser implements IParser { return deduped; } + private mergeParsedMessages(primary: ParsedMessage, secondary: ParsedMessage): ParsedMessage { + const mergedAttachments = this.mergeAttachments(primary.attachments, secondary.attachments); + const mergedCitations = this.mergeCitations(primary.citations, secondary.citations); + const mergedArtifacts = this.mergeArtifacts(primary.artifacts, secondary.artifacts); + + const preferredAst = + this.getAstWeight(secondary.contentAst) > this.getAstWeight(primary.contentAst) + ? secondary.contentAst + : primary.contentAst; + const preferredAstVersion = + preferredAst === secondary.contentAst + ? secondary.contentAstVersion ?? primary.contentAstVersion + : primary.contentAstVersion ?? secondary.contentAstVersion; + const preferredHtmlContent = this.pickPreferredTextBlock(primary.htmlContent, secondary.htmlContent); + const preferredSnapshot = this.pickPreferredTextBlock( + primary.normalizedHtmlSnapshot ?? undefined, + secondary.normalizedHtmlSnapshot ?? undefined, + ); + + return { + role: primary.role, + textContent: + secondary.textContent.trim().length > primary.textContent.trim().length + ? secondary.textContent + : primary.textContent, + contentAst: preferredAst, + contentAstVersion: preferredAstVersion, + degradedNodesCount: Math.max(primary.degradedNodesCount ?? 0, secondary.degradedNodesCount ?? 0), + citations: mergedCitations.length > 0 ? mergedCitations : undefined, + attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined, + artifacts: mergedArtifacts.length > 0 ? mergedArtifacts : undefined, + normalizedHtmlSnapshot: preferredSnapshot ?? undefined, + htmlContent: preferredHtmlContent, + timestamp: primary.timestamp ?? secondary.timestamp, + }; + } + + private mergeAttachments( + primary: ParsedMessage["attachments"], + secondary: ParsedMessage["attachments"], + ): NonNullable { + const merged = new Map[number]>(); + + for (const attachment of [...(primary ?? []), ...(secondary ?? [])]) { + const key = JSON.stringify({ + indexAlt: attachment.indexAlt, + label: attachment.label ?? null, + mime: attachment.mime ?? null, + occurrenceRole: attachment.occurrenceRole, + }); + if (!merged.has(key)) { + merged.set(key, attachment); + } + } + + return Array.from(merged.values()); + } + + private mergeCitations( + primary: ParsedMessage["citations"], + secondary: ParsedMessage["citations"], + ): NonNullable { + const merged = new Map[number]>(); + + for (const citation of [...(primary ?? []), ...(secondary ?? [])]) { + const key = JSON.stringify({ + href: citation.href, + label: citation.label, + host: citation.host, + }); + if (!merged.has(key)) { + merged.set(key, citation); + } + } + + return Array.from(merged.values()); + } + + private mergeArtifacts( + primary: ParsedMessage["artifacts"], + secondary: ParsedMessage["artifacts"], + ): NonNullable { + const merged = new Map[number]>(); + + for (const artifact of [...(primary ?? []), ...(secondary ?? [])]) { + const key = JSON.stringify({ + kind: artifact.kind, + label: artifact.label ?? null, + captureMode: artifact.captureMode ?? null, + renderDimensions: artifact.renderDimensions ?? null, + plainText: artifact.plainText ?? null, + normalizedHtmlSnapshot: artifact.normalizedHtmlSnapshot ?? null, + markdownSnapshot: artifact.markdownSnapshot ?? null, + }); + if (!merged.has(key)) { + merged.set(key, artifact); + } + } + + return Array.from(merged.values()); + } + + private getAstWeight(root: AstRoot | null | undefined): number { + if (!root || !Array.isArray(root.children)) { + return 0; + } + return root.children.length; + } + + private pickPreferredTextBlock( + primary: string | undefined, + secondary: string | undefined, + ): string | undefined { + const primaryLength = primary?.trim().length ?? 0; + const secondaryLength = secondary?.trim().length ?? 0; + if (secondaryLength > primaryLength) { + return secondary; + } + return primary; + } + + private buildMergeSignature(message: ParsedMessage): string { + const normalizedText = message.textContent.replace(/\s+/g, " ").trim(); + if (message.role === "user" && normalizedText.length > 0) { + return [message.role, normalizedText].join("|"); + } + + return this.buildMessageSignature(message); + } + private buildMessageSignature(message: ParsedMessage): string { const attachmentSignature = JSON.stringify( (message.attachments ?? []).map((attachment) => ({ diff --git a/frontend/src/sidepanel/components/MessageBubble.tsx b/frontend/src/sidepanel/components/MessageBubble.tsx index 927ba66..016e894 100644 --- a/frontend/src/sidepanel/components/MessageBubble.tsx +++ b/frontend/src/sidepanel/components/MessageBubble.tsx @@ -1,11 +1,11 @@ import { formatArtifactDescriptor, getArtifactExcerptText } from "@vesti/ui"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Copy, Check, ChevronDown, Link2, Paperclip } from "lucide-react"; +import { Copy, Check, ChevronDown, Link2, Paperclip, Sparkles } from "lucide-react"; import type { Message, Platform } from "~lib/types"; import type { AstRoot } from "~lib/types/ast"; import { AstMessageRenderer } from "./AstMessageRenderer"; -import { DisclosureSection } from "./DisclosureSection"; import { PLATFORM_TONE } from "./platformTone"; +import { ReaderSidecarDisclosure } from "./ReaderSidecarDisclosure"; import { buildMessageFallbackDisplayText, buildMessagePreviewText, @@ -89,6 +89,10 @@ export function MessageBubble({ const isAi = message.role === "ai"; const shouldCollapse = canCollapse && !isExpanded; + const citationCount = message.citations?.length ?? 0; + const attachmentCount = message.attachments?.length ?? 0; + const artifactCount = message.artifacts?.length ?? 0; + const hasSidecars = citationCount > 0 || attachmentCount > 0 || artifactCount > 0; useLayoutEffect(() => { const bodyEl = bodyRef.current; @@ -208,112 +212,6 @@ export function MessageBubble({ ) : null} - {(message.citations ?? []).length > 0 ? ( -
- } - > -
- {(message.citations ?? []).map((citation) => ( - -
- {citation.label} -
-
- {citation.host} -
-
- ))} -
-
-
- ) : null} - - {(message.attachments ?? []).length > 0 ? ( -
- } - > -
- {(message.attachments ?? []).map((attachment, index) => { - const secondaryLabel = - attachment.label && attachment.label !== attachment.indexAlt - ? attachment.label - : null; - - return ( -
-
- {attachment.indexAlt} -
- {secondaryLabel ? ( -
- {secondaryLabel} -
- ) : null} - {attachment.mime ? ( -
- {attachment.mime} -
- ) : null} -
- ); - })} -
-
-
- ) : null} - - {(message.artifacts ?? []).length > 0 ? ( -
- -
- {(message.artifacts ?? []).map((artifact, index) => { - const excerpt = getArtifactExcerptText(artifact, { - maxLines: 2, - maxCharsPerLine: 100, - }); - - return ( -
-
- {artifact.label || artifact.kind} -
-
- {formatArtifactDescriptor(artifact)} -
- {excerpt ? ( -
- {excerpt} -
- ) : null} -
- ); - })} -
-
-
- ) : null} -
{canCollapse ? (
+ + {hasSidecars ? ( +
+ {citationCount > 0 ? ( +
+ } + > +
+ {(message.citations ?? []).map((citation) => ( + +
{citation.label}
+
{citation.host}
+
+ ))} +
+
+
+ ) : null} + + {attachmentCount > 0 ? ( +
+ } + trayVariant="compact" + > +
+ {(message.attachments ?? []).map((attachment, index) => { + const secondaryLabel = + attachment.label && attachment.label !== attachment.indexAlt + ? attachment.label + : null; + + return ( +
+
{attachment.indexAlt}
+ {secondaryLabel ? ( +
{secondaryLabel}
+ ) : null} + {attachment.mime ? ( +
{attachment.mime}
+ ) : null} +
+ ); + })} +
+
+
+ ) : null} + + {artifactCount > 0 ? ( +
+ } + > +
+ {(message.artifacts ?? []).map((artifact, index) => { + const excerpt = getArtifactExcerptText(artifact, { + maxLines: 2, + maxCharsPerLine: 100, + }); + + return ( +
+
+ {artifact.label || artifact.kind} +
+
+ {formatArtifactDescriptor(artifact)} +
+ {excerpt ? ( +
{excerpt}
+ ) : null} +
+ ); + })} +
+
+
+ ) : null} +
+ ) : null} ); } diff --git a/frontend/src/sidepanel/components/ReaderSidecarDisclosure.tsx b/frontend/src/sidepanel/components/ReaderSidecarDisclosure.tsx new file mode 100644 index 0000000..dbd36df --- /dev/null +++ b/frontend/src/sidepanel/components/ReaderSidecarDisclosure.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; + +interface ReaderSidecarDisclosureProps { + title: string; + count: number; + icon: ReactNode; + children: ReactNode; + defaultOpen?: boolean; + trayVariant?: "default" | "compact"; +} + +export function ReaderSidecarDisclosure({ + title, + count, + icon, + children, + defaultOpen = false, + trayVariant = "default", +}: ReaderSidecarDisclosureProps) { + const trayWrapClassName = + trayVariant === "compact" + ? "reader-sidecar-tray-wrap reader-sidecar-tray-wrap-compact" + : "reader-sidecar-tray-wrap"; + const trayClassName = + trayVariant === "compact" + ? "reader-sidecar-tray reader-sidecar-tray-compact" + : "reader-sidecar-tray"; + + return ( +
+ + + {icon} + {title} + + + + {count} + + + + +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index acd6874..9bf9e21 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2827,6 +2827,192 @@ color: hsl(var(--reader-user-text)); } +.reader-turn-sidecars { + display: flex; + flex-direction: column; + gap: 8px; + padding: 2px 15px 11px; +} + +.reader-sidecar-block { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.reader-sidecar-disclosure { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.reader-sidecar-trigger { + display: inline-flex; + align-items: center; + gap: 7px; + cursor: pointer; + list-style: none; + border: 0; + border-radius: 999px; + padding: 3px 8px; + background-color: hsl(var(--reader-expand-bg)); + color: hsl(var(--reader-expand-text)); + transition: background-color 0.12s ease, color 0.12s ease; + marker: none; +} + +.reader-sidecar-trigger::-webkit-details-marker { + display: none; +} + +.reader-sidecar-trigger:hover { + background-color: hsl(var(--reader-expand-hover)); + color: hsl(var(--text-secondary)); +} + +.reader-sidecar-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 2px hsl(var(--accent-primary-light)); +} + +.reader-sidecar-trigger-main { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.reader-sidecar-trigger-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: hsl(var(--text-tertiary)); +} + +.reader-sidecar-trigger-label { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 600; + line-height: 1.1; + letter-spacing: 0.018em; + color: hsl(var(--text-secondary)); +} + +.reader-sidecar-trigger-right { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.reader-sidecar-trigger-count { + min-width: 12px; + font-family: var(--font-ui); + font-size: 9.5px; + font-weight: 600; + line-height: 1; + color: hsl(var(--text-tertiary)); + text-align: right; +} + +.reader-sidecar-trigger-chevron { + width: 13px; + height: 13px; + flex-shrink: 0; + color: hsl(var(--text-tertiary)); + transition: transform 0.2s ease; +} + +.reader-sidecar-disclosure[open] .reader-sidecar-trigger-chevron { + transform: rotate(180deg); +} + +.reader-sidecar-tray-wrap { + width: 100%; + padding-top: 6px; + padding-left: 8px; +} + +.reader-sidecar-tray-wrap-compact { + width: auto; + padding-top: 4px; + padding-left: 6px; +} + +.reader-sidecar-tray { + border: 1px solid hsl(var(--border-subtle) / 0.72); + border-radius: 10px; + background-color: hsl(var(--bg-secondary) / 0.24); + padding: 6px 10px; +} + +.reader-sidecar-tray-compact { + display: inline-flex; + max-width: min(100%, 22rem); + border-radius: 8px; + padding: 3px 8px; +} + +.reader-sidecar-list { + display: flex; + flex-direction: column; +} + +.reader-sidecar-row, +.reader-sidecar-row-link { + display: block; + padding: 5px 0; +} + +.reader-sidecar-row + .reader-sidecar-row, +.reader-sidecar-row + .reader-sidecar-row-link, +.reader-sidecar-row-link + .reader-sidecar-row, +.reader-sidecar-row-link + .reader-sidecar-row-link { + border-top: 1px solid hsl(var(--border-subtle) / 0.58); +} + +.reader-sidecar-row-link { + color: inherit; + text-decoration: none; + transition: opacity 0.12s ease, color 0.12s ease; +} + +.reader-sidecar-row-link:hover { + opacity: 0.88; +} + +.reader-sidecar-row-title { + font-size: 10.75px; + font-weight: 600; + line-height: 1.3; + color: hsl(var(--text-secondary)); +} + +.reader-sidecar-row-attachment .reader-sidecar-row-title { + font-weight: 500; + font-size: 10.5px; +} + +.reader-sidecar-row-attachment { + padding: 2px 0; +} + +.reader-sidecar-row-meta { + margin-top: 2px; + font-size: 10px; + line-height: 1.35; + color: hsl(var(--text-tertiary)); +} + +.reader-sidecar-row-excerpt { + margin-top: 5px; + white-space: pre-wrap; + font-size: 10px; + line-height: 1.4; + color: hsl(var(--text-secondary)); +} + .reader-turn-body p { margin-bottom: 7px; } diff --git a/packages/vesti-ui/src/components/RichMessageContent.tsx b/packages/vesti-ui/src/components/RichMessageContent.tsx index c3842ff..16a15fe 100644 --- a/packages/vesti-ui/src/components/RichMessageContent.tsx +++ b/packages/vesti-ui/src/components/RichMessageContent.tsx @@ -1,4 +1,4 @@ -import { Check, Copy } from "lucide-react"; +import { Check, ChevronDown, Copy, Link2, Paperclip, Sparkles } from "lucide-react"; import katex from "katex"; import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { AstNode, AstRoot, AstTableNode, Message } from "../types"; @@ -317,11 +317,11 @@ function renderArtifactMeta(message: Message): ReactNode { } return ( -
- - Artifacts ({message.artifacts?.length ?? 0}) - -
+ } + > {(message.artifacts ?? []).map((artifact, index) => ( (() => { const excerpt = getArtifactExcerptText(artifact, { @@ -331,16 +331,16 @@ function renderArtifactMeta(message: Message): ReactNode { return (
-
+
{artifact.label || artifact.kind}
-
+
{formatArtifactDescriptor(artifact)}
{excerpt ? ( -
+
{excerpt}
) : null} @@ -348,6 +348,45 @@ function renderArtifactMeta(message: Message): ReactNode { ); })() ))} + + ); +} + +function CompactSidecarSection(props: { + title: string; + count: number; + icon: ReactNode; + children: ReactNode; + trayVariant?: "default" | "compact"; +}) { + const { title, count, icon, children, trayVariant = "default" } = props; + const trayWrapClassName = + trayVariant === "compact" + ? "mt-1 w-auto pl-1.5" + : "mt-1.5 w-full pl-2"; + const trayClassName = + trayVariant === "compact" + ? "divide-y divide-border-subtle/55 rounded-md border border-border-subtle/65 bg-bg-secondary/22 px-2.5 py-1.5" + : "divide-y divide-border-subtle/60 rounded-lg border border-border-subtle/70 bg-bg-secondary/25 px-3 py-2.5"; + + return ( +
+ + + {icon} + + {title} + + + + {count} + + + +
+
+ {children} +
); @@ -359,11 +398,12 @@ function renderAttachmentMeta(message: Message): ReactNode { } return ( -
- - Attachments ({message.attachments?.length ?? 0}) - -
+ } + trayVariant="compact" + > {(message.attachments ?? []).map((attachment, index) => { const secondaryLabel = attachment.label && attachment.label !== attachment.indexAlt @@ -373,22 +413,21 @@ function renderAttachmentMeta(message: Message): ReactNode { return (
-
+
{attachment.indexAlt}
{secondaryLabel ? ( -
{secondaryLabel}
+
{secondaryLabel}
) : null} {attachment.mime ? ( -
{attachment.mime}
+
{attachment.mime}
) : null}
); })} -
-
+ ); } @@ -398,25 +437,24 @@ function renderSourceMeta(message: Message): ReactNode { } return ( -
- - Sources ({message.citations?.length ?? 0}) - -
+ } + > {(message.citations ?? []).map((citation, index) => ( -
{citation.label}
-
{citation.host}
+
{citation.label}
+
{citation.host}
))} -
-
+ ); }