<script lang="ts">
import type { Agent, CodeRef, Message as MessageT, RefHit, RequestRow } from '../types';
import { formatClock, formatIso8601 } from '../format';
import { renderMarkdown, highlightRenderedCodeBlocks } from '../markdown';
import { readState } from '../readState.svelte';
import { api } from '../api';
import { SYSLINE_TYPES, type EventSubject } from '../eventSubject';
import SeenIndicator, { type SeenEntry } from './SeenIndicator.svelte';
import TicketMiniCard from './TicketMiniCard.svelte';
import EventRow from './EventRow.svelte';
import CodeRefCard from './CodeRefCard.svelte';
import CopyIdButton from './CopyIdButton.svelte';
import Icon from './Icon.svelte';
import Kbd from './Kbd.svelte';
interface Props {
message: MessageT;
fromAgent?: Agent;
agents?: Agent[];
request?: RequestRow | null;
hub?: string;
selected?: boolean;
unseen?: boolean;
seenEntries: SeenEntry[];
density?: 'summary' | 'full';
highlight?: boolean;
onSelect?: (id: string) => void;
onSelectTicket?: (id: string) => void;
onOpenRefs?: (ref: CodeRef, hits: RefHit[]) => void;
onOpenFocus?: (id: string) => void;
onOpenNote?: (id: string) => void;
onOpenAgent?: (id: string) => void;
eventSubject?: EventSubject | null;
onOpenEventSubject?: (subject: EventSubject) => void;
}
let {
message,
fromAgent,
agents = [],
request = null,
hub = '',
selected = false,
unseen = false,
seenEntries,
density = 'full',
highlight = false,
onSelect,
onSelectTicket,
onOpenRefs,
onOpenFocus,
onOpenNote,
onOpenAgent,
eventSubject = null,
onOpenEventSubject,
}: Props = $props();
const isSysline = $derived(SYSLINE_TYPES.has(message.type));
const isTicket = $derived(message.type === 'request');
const fromColor = $derived(fromAgent?.color ?? 'var(--muted)');
const fromDisplay = $derived(fromAgent?.display ?? message.from);
const fromAbbr = $derived(fromAgent?.abbr ?? message.from.slice(0, 2).toUpperCase());
const CLAMP_LINES = 10;
const CLAMP_CHARS = 600;
function isLongBody(body: string): boolean {
return body.length > CLAMP_CHARS || body.split('\n').length > CLAMP_LINES;
}
const isLong = $derived(isLongBody(message.body));
let expanded = $state(false);
const showBody = $derived(density === 'full');
const showSummaryLine = $derived(density === 'summary' || isLong);
const renderedBody = $derived(renderMarkdown(message.body));
const codeBlockCount = $derived((renderedBody.match(/<pre class="md-fence"/g) ?? []).length);
let bodyEl: HTMLDivElement | undefined = $state();
function refKey(ref: CodeRef): string {
return `${ref.repo}:${ref.path}@${ref.sha}#${ref.range ? ref.range.join('-') : 'all'}`;
}
function refMatchesInlineText(ref: CodeRef, text: string): boolean {
const m = /^--ref\s+([^:\s]+):(\S+?)(?:@(\S+?))?(?:#\S+)?$/.exec(text.trim());
if (!m) return false;
const [, repo, path, sha] = m;
if (repo !== ref.repo || path !== ref.path) return false;
if (sha && !ref.sha.startsWith(sha)) return false;
return true;
}
async function openAnchoredRef(ref: CodeRef) {
const target = `${ref.repo}:${ref.path}${ref.range ? `@${ref.range[0]}-${ref.range[1]}` : ''}`;
try {
const hits = await api.getRefs(hub, target, true);
onOpenRefs?.(ref, hits);
} catch (err) {
console.error('confer serve: failed to load reverse-index hits', target, err);
}
}
let anchoredRefKeys = $state<Set<string>>(new Set());
function anchorInlineRefs(root: HTMLElement) {
if (density !== 'full' || !message.refs.length) {
if (anchoredRefKeys.size) anchoredRefKeys = new Set();
return;
}
const matched = new Set<string>();
for (const codeEl of Array.from(root.querySelectorAll<HTMLElement>('code.mono'))) {
const text = codeEl.textContent ?? '';
const ref = message.refs.find((r) => refMatchesInlineText(r, text));
if (!ref) continue;
matched.add(refKey(ref));
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'inline-ref-chip mono';
chip.textContent = `◆ ${ref.path}@${ref.sha.slice(0, 7)}`;
chip.title = `${ref.repo}/${ref.path}${ref.range ? ` L${ref.range[0]}-${ref.range[1]}` : ''} — open reverse-index`;
chip.addEventListener('click', (e) => {
e.stopPropagation();
void openAnchoredRef(ref);
});
codeEl.replaceWith(chip);
}
anchoredRefKeys = matched;
}
$effect(() => {
void renderedBody;
void density;
void message.refs;
if (bodyEl) {
void highlightRenderedCodeBlocks(bodyEl);
anchorInlineRefs(bodyEl);
}
});
function selectMessage() {
onSelect?.(message.id);
}
</script>
{#if isSysline}
<EventRow {message} {fromAgent} subject={eventSubject} {highlight} onOpenSubject={onOpenEventSubject} />
{:else}
<div
class="msg"
class:sel={selected}
class:unseen
class:has-ticket={isTicket}
class:pulse={highlight}
data-type={isTicket ? 'request' : 'note'}
data-msg-id={message.id}
role="button"
tabindex="0"
onclick={selectMessage}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') selectMessage();
}}
>
<span class="av" style="color:{fromColor};background:color-mix(in srgb, {fromColor} 18%, transparent)">{fromAbbr}</span>
<div class="body">
<div class="head">
<span class="who" style="color:{fromColor}">{fromDisplay}</span>
{#if message.host}<span class="role">{message.host}</span>{/if}
<span class="ts" title={formatIso8601(message.ts)}>{formatClock(message.ts)}</span>
{#if readState.isDetailViewed(message.id)}
<span class="detail-viewed" title="Opened in the focus reader" aria-label="Opened in the focus reader">✓</span>
{/if}
<CopyIdButton id={message.id} class="msg-copy-id" />
{#if onOpenFocus}
<button
type="button"
class="msg-focus-btn"
title="Open in focus reader"
aria-label="Open in focus reader"
onclick={(e) => {
e.stopPropagation();
onOpenFocus?.(message.id);
}}
>
<Icon name="arrow-up-right" size={12} />
<Kbd keys="f" />
</button>
{/if}
{#if onOpenNote && !isTicket}
<button
type="button"
class="msg-inspect-btn"
title="Open note (body + related tickets/code/thread)"
aria-label="Open note detail"
onclick={(e) => {
e.stopPropagation();
onOpenNote?.(message.id);
}}
>
<Icon name="maximize" size={12} />
</button>
{/if}
<SeenIndicator entries={seenEntries} {onOpenAgent} />
</div>
{#if isTicket && request}
<TicketMiniCard {request} {agents} onSelect={onSelectTicket} />
{:else}
{#if showSummaryLine}
<div class="summary-line">
<span class="lead">{message.summary}</span>
{#if density === 'summary'}
<span class="chips">
{#if message.refs.length}
<button
type="button"
class="chip ref"
title="{message.refs.length} code reference{message.refs.length === 1 ? '' : 's'} — open in focus reader"
onclick={(e) => {
e.stopPropagation();
onOpenFocus?.(message.id);
}}
>
◆ {message.refs.length}
</button>
{/if}
{#if codeBlockCount}
<button
type="button"
class="chip code"
title="{codeBlockCount} code block{codeBlockCount === 1 ? '' : 's'} — open in focus reader"
onclick={(e) => {
e.stopPropagation();
onOpenFocus?.(message.id);
}}
>
⟨⟩ {codeBlockCount}
</button>
{/if}
</span>
{/if}
</div>
{/if}
{#if showBody}
<div class="text-wrap" class:clamped={isLong && !expanded}>
<div class="text prose md" bind:this={bodyEl}>
{@html renderedBody}
</div>
{#if isLong && !expanded}<div class="fade"></div>{/if}
</div>
{#if isLong}
<button
type="button"
class="expand-toggle block"
class:open={expanded}
aria-expanded={expanded}
aria-label={expanded ? 'Collapse message' : 'Expand message'}
onclick={(e) => {
e.stopPropagation();
expanded = !expanded;
}}
>
<span>{expanded ? 'Show less' : 'Show more'}</span>
<svg class="chev" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
<polyline points="4 6 8 10 12 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
{/if}
{#if message.refs.length}
{#each message.refs.filter((r) => !anchoredRefKeys.has(refKey(r))) as ref, i (refKey(ref) + '#' + i)}
<CodeRefCard {ref} {hub} onRevHook={onOpenRefs} />
{/each}
{/if}
{/if}
{/if}
</div>
</div>
{/if}
<style>
.msg {
position: relative;
display: flex;
gap: 12px;
padding: 9px 10px;
border-radius: 10px;
margin: 1px -10px;
cursor: pointer;
text-align: left;
border: 0;
background: transparent;
width: 100%;
font: inherit;
color: inherit;
}
.msg:hover {
background: var(--panel);
}
.msg.sel {
background: var(--panel);
box-shadow: inset 0 0 0 1px var(--border-2);
}
.msg.has-ticket:hover {
background: transparent;
}
.msg.unseen::before {
content: '';
position: absolute;
left: -4px;
top: 9px;
bottom: 9px;
width: 3px;
border-radius: 2px;
background: var(--accent);
box-shadow: 0 0 9px -1px var(--accent);
}
.msg:hover :global(.msg-copy-id),
.msg:focus-within :global(.msg-copy-id) {
opacity: 1;
}
.msg-focus-btn {
display: inline-flex;
align-items: center;
gap: 4px;
opacity: 0;
border: 1px solid var(--border-2);
background: var(--panel-2);
color: var(--muted);
border-radius: 6px;
padding: 2px 6px;
transition: opacity 0.12s ease;
}
.msg-focus-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.msg:hover .msg-focus-btn,
.msg:focus-within .msg-focus-btn,
.msg-focus-btn:focus-visible {
opacity: 1;
}
.msg-inspect-btn {
display: inline-flex;
align-items: center;
opacity: 0;
border: 1px solid var(--border-2);
background: var(--panel-2);
color: var(--muted);
border-radius: 6px;
padding: 2px 6px;
transition: opacity 0.12s ease;
}
.msg-inspect-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.msg:hover .msg-inspect-btn,
.msg:focus-within .msg-inspect-btn,
.msg-inspect-btn:focus-visible {
opacity: 1;
}
@keyframes msg-pulse {
0% {
box-shadow: inset 0 0 0 0px var(--accent);
background: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
100% {
box-shadow: inset 0 0 0 0px var(--accent);
background: transparent;
}
}
.msg.pulse {
animation: msg-pulse 2s ease-out;
}
.av {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
flex: 0 0 auto;
font: 700 10.5px/1 var(--mono);
}
.body {
min-width: 0;
flex: 1;
}
.head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.who {
font-weight: 650;
font-size: 13px;
}
.role {
font: 500 10.5px/1 var(--mono);
color: var(--faint);
border: 1px solid var(--border-2);
padding: 2px 5px;
border-radius: 5px;
}
.ts {
font: 500 11px/1 var(--mono);
color: var(--faint);
}
.detail-viewed {
font: 700 10px/1 var(--mono);
color: var(--done);
opacity: 0.75;
}
.summary-line {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--text);
margin-bottom: 4px;
}
.summary-line .lead {
font-weight: 650;
min-width: 0;
line-height: 1.42;
overflow-wrap: anywhere;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chips {
flex: 0 0 auto;
display: flex;
gap: 4px;
margin-left: auto;
}
.chip {
font: 600 10px/1 var(--mono);
padding: 2px 6px;
border-radius: 5px;
border: 1px solid var(--border-2);
color: var(--muted);
background: var(--panel-2);
}
.chip:hover {
color: var(--text);
border-color: var(--accent);
}
.chip.ref {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}
.chip.code {
color: var(--claimed);
border-color: color-mix(in srgb, var(--claimed) 35%, transparent);
}
.expand-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
border: 1px solid var(--border-2);
border-radius: 999px;
background: var(--panel-2);
padding: 3px 9px 3px 10px;
color: var(--muted);
font: 600 11px/1.2 var(--mono);
letter-spacing: 0.01em;
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
}
.expand-toggle .chev {
transform: rotate(0deg);
transition: transform 0.15s ease;
}
.expand-toggle.open .chev {
transform: rotate(180deg);
}
.expand-toggle:hover,
.expand-toggle:focus-visible {
color: var(--text);
background: var(--panel-3);
border-color: var(--accent);
}
.expand-toggle.block {
margin: 6px 0 0;
}
.text {
font-size: 13.5px;
color: var(--text);
}
.text-wrap {
position: relative;
}
.text-wrap.clamped {
max-height: 168px;
overflow: hidden;
}
.text-wrap .fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 56px;
background: linear-gradient(to bottom, transparent, var(--bg) 88%);
pointer-events: none;
}
</style>