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
2 changes: 1 addition & 1 deletion web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,7 @@
{#if showChannelModal}
<ChannelModal
onClose={() => showChannelModal = false}
onCreate={(id, topic) => { store.createChannel(id, topic); showChannelModal = false; }}
onCreate={(id, topic, isPrivate) => { store.createChannel(id, topic, isPrivate); showChannelModal = false; }}
/>
{/if}

Expand Down
21 changes: 21 additions & 0 deletions web/src/components/ArtifactDetailHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@
showCompareDropdown = !showCompareDropdown;
}

/**
* Close the Compare dropdown on an outside click. The Compare dropdown's
* open state (`showCompareDropdown`) is owned locally in this component
* (unlike the primary dropdown, whose state lives in ArtifactPanel and is
* closed by that file's window guard), so the outside-click listener
* belongs here too. Mirrors ArtifactPanel.handleVersionDropdownOutsideClick:
* the trigger + listbox both live inside
* `[data-testid="compare-version-selector"]`, so a mousedown anywhere
* outside that subtree closes the dropdown.
* @param {MouseEvent} e
*/
function handleCompareDropdownOutsideClick(e) {
if (!showCompareDropdown) return;
const node = e.target;
const el = node instanceof Element ? node : (node?.parentElement ?? null);
if (el && el.closest('[data-testid="compare-version-selector"]')) return;
showCompareDropdown = false;
}

// Diff mode is disabled when there is only a single version to compare.
let diffDisabled = $derived(!hasMultipleVersions);

Expand Down Expand Up @@ -325,6 +344,8 @@
);
</script>

<svelte:window onmousedown={handleCompareDropdownOutsideClick} />

<div class="artifact-header">
<div class="artifact-header-top">
<button class="artifact-back-btn" onclick={onBack} title="Back to list" aria-label="Back to artifact list">
Expand Down
184 changes: 111 additions & 73 deletions web/src/components/MessageBubble.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -110,29 +110,36 @@
// loud-notify yourself about your own message.
const viewerIsSender = viewerKey !== null && senderKey !== null && viewerKey === senderKey;

const result = [];
for (const t of richTokens) {
if (t.type === 'inline-code') {
result.push({ type: 'inline-code', value: t.value });
continue;
}
if (t.type === 'block-code' || t.type === 'unclosed-block') {
// Existing CodeBlock component handles fenced rendering with shiki;
// keep that pipeline for parity with the markdown-rendered surface.
result.push({ type: 'codeblock', language: t.lang || '', code: t.value });
continue;
}
if (t.type === 'bold' || t.type === 'italic' || t.type === 'strike') {
// Emphasis tokens come from `parseRich` (read-side only). The inner
// value is plain text (flat-only v1 — no nesting), so mentions/links
// INSIDE *foo* / **bar** won't render as pills/links. That's a fair
// tradeoff for v1; we can revisit with a recursive parse if Phil
// hits a real case.
result.push({ type: t.type, value: t.value });
continue;
// Coalesce adjacent text segments that result from invalid-mention
// rewriting (e.g. "literal @mention here" becomes 3 text segments;
// joining them keeps the rendered output identical to a single text
// node and avoids stray inline boundaries the user could see as a
// layout artifact). Same-type, same-attribute segments only.
function coalesceText(segs) {
const out = [];
for (const seg of segs) {
const prev = out[out.length - 1];
if (prev && prev.type === 'text' && seg.type === 'text') {
prev.value += seg.value;
} else {
out.push(seg);
}
}
// text token: run mention + URL splitting (preserves prior behavior).
const mentionSegments = parseMentions(t.value);
return out;
}

// Split a plain-text run into mention / mention-self / mention-other /
// link / text segments. Shared by top-level `text` tokens AND the inner
// value of emphasis tokens (bold / italic / strike), so an `@mention`
// inside `**bold**` / `*italic*` / `~~strike~~` still resolves to a
// mention chip (primary bugfix — emphasis no longer swallows mentions).
//
// Code spans (inline-code / block-code) never reach here — they're
// emitted as their own tokens and pushed literally — so an `@name`
// inside backtick `code` stays literal and is never linkified.
function splitTextToSegments(text) {
const segs = [];
const mentionSegments = parseMentions(text);
for (const mseg of mentionSegments) {
if (mseg.type === 'mention') {
// Validate the @-prefixed value against the live participants set.
Expand All @@ -150,17 +157,17 @@
const broadcastName = mseg.value.startsWith('@') ? mseg.value.slice(1) : mseg.value;
const viewerTargeted = viewerKey !== null && mentionsSet.has(viewerKey);
if (viewerTargeted && !viewerIsSender) {
result.push({ type: 'mention-self', name: broadcastName, key: '__broadcast__' });
segs.push({ type: 'mention-self', name: broadcastName, key: '__broadcast__' });
} else {
result.push({ type: 'mention-other', name: broadcastName, key: '__broadcast__' });
segs.push({ type: 'mention-other', name: broadcastName, key: '__broadcast__' });
}
continue;
}
const resolvedKey = nameToKey.get(candidateName);
if (resolvedKey === undefined) {
// Unknown participant — render as plain text (existing
// behavior, prevents "@example" literal chips).
result.push({ type: 'text', value: mseg.value });
segs.push({ type: 'text', value: mseg.value });
continue;
}
// Resolved to a real participant. Classify against the wire
Expand All @@ -176,63 +183,79 @@
// Sender-self special case (§6.3 step 4): viewer IS the
// sender. Downgrade to legacy `.mention` — not loud,
// and not "other" either. Don't loud-notify yourself.
result.push(mseg);
segs.push(mseg);
} else {
// Self-mention. Loud styling. The bubble border accent
// is computed from `bodySegments` after parseBody
// returns.
result.push({ type: 'mention-self', name: displayName, key: resolvedKey });
segs.push({ type: 'mention-self', name: displayName, key: resolvedKey });
}
} else {
// Other-mention. Quiet styling. Includes the case where
// the sender is the viewer AND someone ELSE is also
// mentioned — the loop only sees one segment per `@name`
// token, and this branch handles the non-self ones.
result.push({ type: 'mention-other', name: displayName, key: resolvedKey });
segs.push({ type: 'mention-other', name: displayName, key: resolvedKey });
}
} else {
// Legacy / unkeyed / whisper / mentions-inactive — keep the
// existing chip styling. Backwards-compat slot, also covers
// whisper-with-named-recipient per §6.3 R2-C1.
result.push(mseg);
segs.push(mseg);
}
continue;
}
if (mseg.type !== 'text') {
result.push(mseg);
segs.push(mseg);
continue;
}
let lastIndex = 0;
let match;
LINK_REGEX.lastIndex = 0;
while ((match = LINK_REGEX.exec(mseg.value)) !== null) {
if (match.index > lastIndex) {
result.push({ type: 'text', value: mseg.value.slice(lastIndex, match.index) });
segs.push({ type: 'text', value: mseg.value.slice(lastIndex, match.index) });
}
result.push({ type: 'link', value: match[0] });
segs.push({ type: 'link', value: match[0] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < mseg.value.length) {
result.push({ type: 'text', value: mseg.value.slice(lastIndex) });
segs.push({ type: 'text', value: mseg.value.slice(lastIndex) });
}
}
return coalesceText(segs);
}

// Coalesce adjacent text segments that result from invalid-mention
// rewriting (e.g. "literal @mention here" becomes 3 text segments;
// joining them keeps the rendered output identical to a single
// text node and avoids stray inline boundaries the user could see
// as a layout artifact). Same-type, same-attribute segments only.
const coalesced = [];
for (const seg of result) {
const prev = coalesced[coalesced.length - 1];
if (prev && prev.type === 'text' && seg.type === 'text') {
prev.value += seg.value;
} else {
coalesced.push(seg);
const result = [];
for (const t of richTokens) {
if (t.type === 'inline-code') {
result.push({ type: 'inline-code', value: t.value });
continue;
}
if (t.type === 'block-code' || t.type === 'unclosed-block') {
// Existing CodeBlock component handles fenced rendering with shiki;
// keep that pipeline for parity with the markdown-rendered surface.
result.push({ type: 'codeblock', language: t.lang || '', code: t.value });
continue;
}
if (t.type === 'bold' || t.type === 'italic' || t.type === 'strike') {
// Emphasis tokens from `parseRich` (read-side only) carry a plain-
// text inner `value` (flat-only v1 — no nested code). Run the same
// mention + link splitting over that value and stash the result as
// `children`; the template renders them INSIDE the emphasis wrapper
// so a mention/link inside *foo* / **bar** / ~~baz~~ gets BOTH the
// emphasis styling (inherited from the wrapper) AND its own chip /
// link styling. Fixes the "@mention in bold loses its color" bug.
result.push({ type: t.type, value: t.value, children: splitTextToSegments(t.value) });
continue;
}
// text token: run mention + URL splitting (preserves prior behavior).
for (const seg of splitTextToSegments(t.value)) {
result.push(seg);
}
}
return coalesced;

return coalesceText(result);
}

let { message, consecutive = false, currentUser, participants, onOpenThread, onContextMenu, onShowProfile, onReact, onMore, onRetry } = $props();
Expand All @@ -252,10 +275,17 @@
let senderColor = $derived(getParticipantColor(message.sender.key));
let bodySegments = $derived(parseBody(message.body, participants, message, currentUser));
// True iff ANY rendered segment is `mention-self` — drives the bubble
// border accent (`.has-self-mention`). The sender-self downgrade is
// already applied in parseBody, so this naturally returns false for
// the sender's own bubble and won't paint the loud border on it.
let hasSelfMention = $derived(bodySegments.some(s => s.type === 'mention-self'));
// border accent (`.has-self-mention`). Recurses into emphasis `children`
// so a self-mention inside **bold** / *italic* still paints the accent.
// The sender-self downgrade is already applied in parseBody, so this
// naturally returns false for the sender's own bubble.
function segsHaveSelfMention(segs) {
return segs.some(s =>
s.type === 'mention-self' ||
(Array.isArray(s.children) && segsHaveSelfMention(s.children))
);
}
let hasSelfMention = $derived(segsHaveSelfMention(bodySegments));
let hasCode = $derived(message.body.includes('```'));

// Detect URLs in message body for link previews
Expand Down Expand Up @@ -324,6 +354,36 @@
}
</script>

<!--
Recursive segment renderer. Top-level body segments and the `children`
of emphasis (bold/italic/strike) segments share one render path, so a
mention or link nested inside emphasis gets its own chip/link styling
while inheriting the emphasis styling from the wrapper element.
-->
{#snippet renderSeg(seg)}
{#if seg.type === 'mention'}
<span class="mention">{seg.value}</span>
{:else if seg.type === 'mention-self'}
<span class="mention-chip-self">@{seg.name}</span>
{:else if seg.type === 'mention-other'}
<span class="mention-chip-other">@{seg.name}</span>
{:else if seg.type === 'link'}
<a class="inline-link" href={seg.value} target="_blank" rel="noopener noreferrer">{seg.value}</a>
{:else if seg.type === 'codeblock'}
<CodeBlock language={seg.language} code={seg.code} />
{:else if seg.type === 'inline-code'}
<span class="code-chip">{seg.value}</span>
{:else if seg.type === 'bold'}
<strong class="md-bold">{#each seg.children as c, i (i)}{@render renderSeg(c)}{/each}</strong>
{:else if seg.type === 'italic'}
<em class="md-italic">{#each seg.children as c, i (i)}{@render renderSeg(c)}{/each}</em>
{:else if seg.type === 'strike'}
<span class="md-strike">{#each seg.children as c, i (i)}{@render renderSeg(c)}{/each}</span>
{:else}
{seg.value}
{/if}
{/snippet}

<div
class="msg-row"
class:claude={!isHuman}
Expand Down Expand Up @@ -371,29 +431,7 @@
{/if}

<div class="bubble" class:bubble-targeted={isTargeted} class:has-self-mention={hasSelfMention}>
{#each bodySegments as seg, i (i)}
{#if seg.type === 'mention'}
<span class="mention">{seg.value}</span>
{:else if seg.type === 'mention-self'}
<span class="mention-chip-self">@{seg.name}</span>
{:else if seg.type === 'mention-other'}
<span class="mention-chip-other">@{seg.name}</span>
{:else if seg.type === 'link'}
<a class="inline-link" href={seg.value} target="_blank" rel="noopener noreferrer">{seg.value}</a>
{:else if seg.type === 'codeblock'}
<CodeBlock language={seg.language} code={seg.code} />
{:else if seg.type === 'inline-code'}
<span class="code-chip">{seg.value}</span>
{:else if seg.type === 'bold'}
<strong class="md-bold">{seg.value}</strong>
{:else if seg.type === 'italic'}
<em class="md-italic">{seg.value}</em>
{:else if seg.type === 'strike'}
<span class="md-strike">{seg.value}</span>
{:else}
{seg.value}
{/if}
{/each}
{#each bodySegments as seg, i (i)}{@render renderSeg(seg)}{/each}
</div>

{#each detectedUrls as link (link.url)}
Expand Down
Loading