<script lang="ts">
import type { Message } from "../../api/types.js";
import {
parseContent,
enrichSegments,
} from "../../utils/content-parser.js";
import { formatTimestamp } from "../../utils/format.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { messages as messagesStore } from "../../stores/messages.svelte.js";
import ThinkingBlock from "./ThinkingBlock.svelte";
import ToolBlock from "./ToolBlock.svelte";
import CodeBlock from "./CodeBlock.svelte";
import SkillBlock from "./SkillBlock.svelte";
import { ui } from "../../stores/ui.svelte.js";
import { pins } from "../../stores/pins.svelte.js";
import { sessions } from "../../stores/sessions.svelte.js";
import { applyHighlight } from "../../utils/highlight.js";
import { renderMarkdown } from "../../utils/markdown.js";
import type { Session } from "../../api/types.js";
import AppIcon from "../layout/AppIcon.svelte";
interface Props {
message: Message;
isSubagentContext?: boolean;
highlightQuery?: string;
isCurrentHighlight?: boolean;
}
let { message, isSubagentContext = false, highlightQuery = "", isCurrentHighlight = false }: Props = $props();
let copied = $state(false);
let segments = $derived(
enrichSegments(
parseContent(message.content, message.has_tool_use),
message.tool_calls,
),
);
let isUser = $derived(message.role === "user");
let mainModel = $derived(
!isSubagentContext &&
messagesStore.sessionId === message.session_id
? messagesStore.mainModel
: "",
);
let offMainModel = $derived.by((): string => {
if (isUser || !message.model || !mainModel) return "";
return message.model !== mainModel ? message.model : "";
});
/** Resolve the session that owns this message, falling back to activeSession. */
let owningSession = $derived(
sessions.sessions.find((s) => s.id === message.session_id) ??
sessions.activeSession,
);
/** Walk the parent chain to check if any ancestor has the teammate tag. */
function isTeammateAncestry(s: Session, all: Session[]): boolean {
if ((s.first_message ?? "").includes("<teammate-message")) return true;
if (!s.parent_session_id) return false;
const visited = new Set<string>();
let cur: Session | undefined = s;
while (cur?.parent_session_id && !visited.has(cur.id)) {
visited.add(cur.id);
const parent = all.find((p) => p.id === cur!.parent_session_id);
if (!parent) break;
if ((parent.first_message ?? "").includes("<teammate-message")) return true;
cur = parent;
}
return false;
}
/** Walk the parent chain to check if any ancestor is a subagent. */
function isSubagentAncestry(s: Session, all: Session[]): boolean {
if (s.relationship_type === "subagent") return true;
if (!s.parent_session_id) return false;
const visited = new Set<string>();
let cur: Session | undefined = s;
while (cur?.parent_session_id && !visited.has(cur.id)) {
visited.add(cur.id);
const parent = all.find((p) => p.id === cur!.parent_session_id);
if (!parent) break;
if (parent.relationship_type === "subagent") return true;
cur = parent;
}
return false;
}
/** Classify the session kind, walking the parent chain. */
let sessionKind = $derived.by((): "teammate" | "subagent" | "user" => {
const s = owningSession;
if (!s) return "user";
const all = sessions.sessions;
if (isSubagentAncestry(s, all)) return "subagent";
if (isTeammateAncestry(s, all)) return "teammate";
return "user";
});
/** Context-aware role labels based on session type. */
let roleLabel = $derived.by(() => {
if (!isUser) return "Assistant";
if (isSubagentContext) return "Agent";
if (sessionKind === "teammate") return "Teammate";
if (sessionKind === "subagent") return "Agent";
return "User";
});
let roleIcon = $derived.by(() => {
if (!isUser) return "A";
if (isSubagentContext) return "S";
if (sessionKind === "teammate") return "T";
if (sessionKind === "subagent") return "S";
return "U";
});
let hasSearchQuery = $derived(highlightQuery.trim() !== "");
/** Whether the text (prose) segments for this role should render. */
let showText = $derived(
hasSearchQuery || ui.isBlockVisible(isUser ? "user" : "assistant"),
);
let accentColor = $derived(
isUser ? "var(--vscode-focusBorder)" : "var(--vscode-textPreformat-foreground)",
);
let roleBg = $derived(
isUser ? "var(--wb-user-bg)" : "var(--wb-assistant-bg)",
);
let pinned = $derived(owningSession ? pins.isPinned(message, owningSession) : false);
let pinFeedback = $state("");
let copyTimer: ReturnType<typeof setTimeout>;
let pinTimer: ReturnType<typeof setTimeout>;
async function handleCopy() {
try {
await copyToClipboard(message.content);
clearTimeout(copyTimer);
copied = true;
copyTimer = setTimeout(() => { copied = false; }, 1500);
} catch { /* clipboard unavailable */ }
}
/** Pin or unpin, and say which — or why neither could be done. */
async function handleTogglePin() {
if (!owningSession) return;
const wasPinned = pinned;
const done = await pins.toggle(message, owningSession);
clearTimeout(pinTimer);
pinFeedback = done ? (wasPinned ? "Unpinned" : "Pinned") : (pins.error ?? "Not pinned");
pinTimer = setTimeout(() => { pinFeedback = ""; }, done ? 1500 : 6000);
}
</script>
<div
class="message"
class:is-user={isUser}
style:border-left-color={accentColor}
style:background={roleBg}
>
<div class="message-header">
<span
class="role-icon"
style:background={accentColor}
>
{roleIcon}
</span>
<span
class="role-label"
style:color={accentColor}
>
{roleLabel}
</span>
<button
type="button"
class="copy-btn"
title={copied ? "Copied!" : "Copy message"}
onclick={handleCopy}
>
{#if copied}
<AppIcon name="check" size={14} />
{:else}
<AppIcon name="copy" size={14} />
{/if}
</button>
<button
type="button"
class="pin-btn"
class:pinned
title={pinned ? "Unpin message" : "Pin message to its project"}
disabled={!owningSession}
onclick={handleTogglePin}
>
<AppIcon name="pin" size={14} />
</button>
{#if pinFeedback}
<span class="pin-feedback">{pinFeedback}</span>
{/if}
<span class="timestamp">
{formatTimestamp(message.timestamp)}
</span>
{#if offMainModel}
<span class="message-model" title={offMainModel}>
{offMainModel}
</span>
{/if}
</div>
<div class="message-body">
{#each segments as segment}
{#if segment.type === "thinking"}
{#if hasSearchQuery || ui.isBlockVisible("thinking")}
<ThinkingBlock
content={segment.content}
highlightQuery={highlightQuery}
isCurrentHighlight={isCurrentHighlight}
/>
{/if}
{:else if segment.type === "tool"}
{#if hasSearchQuery || ui.isBlockVisible("tool")}
<ToolBlock
content={segment.content}
label={segment.label}
toolCall={segment.toolCall}
highlightQuery={highlightQuery}
isCurrentHighlight={isCurrentHighlight}
/>
{/if}
{:else if segment.type === "code"}
{#if hasSearchQuery || ui.isBlockVisible("code")}
<CodeBlock
content={segment.content}
language={segment.label}
highlightQuery={highlightQuery}
isCurrentHighlight={isCurrentHighlight}
/>
{/if}
{:else if segment.type === "skill"}
{#if showText}
<SkillBlock content={segment.content} name={segment.label} />
{/if}
{:else}
{#if showText}
<div
class="text-content markdown"
use:applyHighlight={{
q: highlightQuery,
current: isCurrentHighlight,
content: segment.content,
}}
>
{@html renderMarkdown(segment.content)}
</div>
{/if}
{/if}
{/each}
</div>
</div>
<style>
.message {
border-left: 4px solid;
padding: var(--wb-spacing-14) var(--wb-spacing-20);
border-radius: 0 var(--wsb-radius-md) var(--wsb-radius-md) 0;
}
.message-header {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
margin-bottom: var(--wb-spacing-10);
}
.role-icon {
width: var(--wb-control-size-22);
height: var(--wb-control-size-22);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--wb-font-size-11);
font-weight: 700;
color: white;
flex-shrink: 0;
line-height: 1;
}
.role-label {
font-size: var(--wb-font-size-13);
font-weight: 600;
letter-spacing: 0.01em;
}
.timestamp {
font-size: var(--wb-font-size-12);
color: var(--vscode-disabledForeground);
margin-left: auto;
}
.message-model {
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
padding: var(--wb-spacing-1) var(--wb-spacing-4);
border-radius: var(--wb-corner-radius-3);
background: var(--vscode-input-background);
white-space: nowrap;
flex-shrink: 0;
opacity: 0.8;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: var(--wb-control-size-26);
height: var(--wb-control-size-26);
border: none;
border-radius: var(--wsb-radius-sm, var(--wb-corner-radius-4));
background: transparent;
color: var(--vscode-disabledForeground);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0;
}
.message:hover .copy-btn,
.copy-btn:focus-visible {
opacity: 1;
}
@media (hover: none) {
.copy-btn {
opacity: 1;
}
}
.copy-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-foreground);
}
.copy-btn:active {
transform: scale(0.92);
}
.pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: var(--wb-control-size-26);
height: var(--wb-control-size-26);
border: none;
border-radius: var(--wsb-radius-sm, var(--wb-corner-radius-4));
background: transparent;
color: var(--vscode-disabledForeground);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0;
}
.message:hover .pin-btn,
.pin-btn:focus-visible,
.pin-btn.pinned {
opacity: 1;
}
@media (hover: none) {
.pin-btn {
opacity: 1;
}
}
.pin-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-foreground);
}
.pin-btn.pinned {
color: var(--vscode-focusBorder);
}
.pin-btn:active {
transform: scale(0.92);
}
.pin-feedback {
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
animation: fade-in-out 1.5s ease-in-out;
}
@keyframes fade-in-out {
0% { opacity: 0; }
15% { opacity: 1; }
75% { opacity: 1; }
100% { opacity: 0; }
}
.text-content {
font-size: var(--wb-font-size-14);
line-height: 1.7;
color: var(--vscode-editor-foreground);
word-wrap: break-word;
}
.message-body {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-8);
}
/* Markdown prose styles */
.markdown :global(p) {
margin: 0.5em 0;
}
.markdown :global(p:first-child) {
margin-top: 0;
}
.markdown :global(p:last-child) {
margin-bottom: 0;
}
.markdown :global(h1),
.markdown :global(h2),
.markdown :global(h3),
.markdown :global(h4),
.markdown :global(h5),
.markdown :global(h6) {
margin: 0.8em 0 0.4em;
line-height: 1.3;
font-weight: 600;
}
.markdown :global(h1) { font-size: 1.35em; }
.markdown :global(h2) { font-size: 1.2em; }
.markdown :global(h3) { font-size: 1.1em; }
.markdown :global(h4),
.markdown :global(h5),
.markdown :global(h6) { font-size: 1em; }
.markdown :global(a) {
color: var(--vscode-focusBorder);
text-decoration: none;
}
.markdown :global(a:hover) {
text-decoration: underline;
}
.markdown :global(code) {
font-family: var(--wsb-font-mono);
font-size: 0.85em;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-widget-border);
border-radius: var(--wb-corner-radius-4);
padding: 0.15em 0.4em;
}
.markdown :global(pre) {
background: var(--wb-code-bg);
color: var(--wb-code-text);
border-radius: var(--wsb-radius-md);
padding: var(--wb-spacing-12) var(--wb-spacing-16);
overflow-x: auto;
margin: 0.5em 0;
}
.markdown :global(pre code) {
background: none;
border: none;
padding: 0;
font-size: var(--wb-font-size-13);
color: inherit;
}
.markdown :global(blockquote) {
border-left: 3px solid var(--vscode-panel-border);
margin: 0.5em 0;
padding: 0.3em 1em;
color: var(--vscode-foreground);
}
.markdown :global(ul),
.markdown :global(ol) {
padding-left: 1.6em;
margin: 0.5em 0;
}
.markdown :global(li) {
margin: 0.2em 0;
line-height: 1.65;
}
.markdown :global(hr) {
border: none;
border-top: 1px solid var(--vscode-widget-border);
margin: 0.8em 0;
}
.markdown :global(table) {
border-collapse: collapse;
margin: 0.5em 0;
width: auto;
font-size: var(--wb-font-size-13);
}
.markdown :global(th),
.markdown :global(td) {
border: 1px solid var(--vscode-widget-border);
padding: var(--wb-spacing-6) var(--wb-spacing-10);
text-align: left;
}
.markdown :global(th) {
background: var(--vscode-input-background);
font-weight: 600;
}
.markdown :global(img) {
max-width: 100%;
border-radius: var(--wsb-radius-sm);
}
.markdown :global(strong) {
font-weight: 600;
}
</style>