Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
10 changes: 10 additions & 0 deletions documents/ui_refactor/v1_4_ui_refactor_engineering_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
154 changes: 147 additions & 7 deletions frontend/src/lib/core/parser/gemini/GeminiParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,21 +863,161 @@ 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);
}
}

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<ParsedMessage["attachments"]> {
const merged = new Map<string, NonNullable<ParsedMessage["attachments"]>[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<ParsedMessage["citations"]> {
const merged = new Map<string, NonNullable<ParsedMessage["citations"]>[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<ParsedMessage["artifacts"]> {
const merged = new Map<string, NonNullable<ParsedMessage["artifacts"]>[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) => ({
Expand Down
Loading
Loading