Skip to content
Draft
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
1 change: 1 addition & 0 deletions frontend/src/lib/api/types/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface Session {
file_mtime?: number;
total_output_tokens: number;
peak_context_tokens: number;
main_model?: string;
created_at: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function makeSession(id: string, agent: string) {
user_message_count: 1,
total_output_tokens: 0,
peak_context_tokens: 0,
main_model: "",
created_at: "2026-02-20T12:30:00Z",
};
}
Expand Down
39 changes: 35 additions & 4 deletions frontend/src/lib/components/content/MessageContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
parseContent,
enrichSegments,
} from "../../utils/content-parser.js";
import { formatTimestamp } from "../../utils/format.js";
import { formatTimestamp, shortModelName } from "../../utils/format.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import ThinkingBlock from "./ThinkingBlock.svelte";
import ToolBlock from "./ToolBlock.svelte";
Expand All @@ -22,9 +22,10 @@
isSubagentContext?: boolean;
highlightQuery?: string;
isCurrentHighlight?: boolean;
owningSession?: Session | null;
}

let { message, isSubagentContext = false, highlightQuery = "", isCurrentHighlight = false }: Props = $props();
let { message, isSubagentContext = false, highlightQuery = "", isCurrentHighlight = false, owningSession: owningSessionProp = null }: Props = $props();

let copied = $state(false);

Expand All @@ -37,12 +38,28 @@

let isUser = $derived(message.role === "user");

/** Resolve the session that owns this message, falling back to activeSession. */
/**
* Resolve the session that owns this message. If an explicit owningSession
* prop is provided (e.g. from SubagentInline which already has the child
* session), use it. Otherwise look up by session_id in sessions.sessions,
* falling back to activeSession.
*/
let owningSession = $derived(
sessions.sessions.find((s) => s.id === message.session_id) ??
owningSessionProp ??
sessions.sessions.find((s) => s.id === message.session_id) ??
sessions.activeSession,
);

/**
* Show the message's model name when it differs from the session's main
* model. Only applies to assistant messages — user messages carry no model.
*/
let offMainModel = $derived.by((): string => {
if (isUser || !message.model) return "";
const mainModel = owningSession?.main_model ?? "";
return message.model !== mainModel ? message.model : "";
});

/** 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;
Expand Down Expand Up @@ -202,6 +219,9 @@
<span class="timestamp">
{formatTimestamp(message.timestamp)}
</span>
{#if offMainModel}
<span class="message-model" title={offMainModel}>{shortModelName(offMainModel)}</span>
{/if}
</div>

<div class="message-body">
Expand Down Expand Up @@ -295,6 +315,17 @@
margin-left: auto;
}

.message-model {
font-size: 10px;
color: var(--text-muted);
padding: 1px 4px;
border-radius: 3px;
background: var(--bg-tertiary);
white-space: nowrap;
flex-shrink: 0;
opacity: 0.8;
}

.copy-btn {
display: flex;
align-items: center;
Expand Down
194 changes: 194 additions & 0 deletions frontend/src/lib/components/content/MessageContent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// @vitest-environment jsdom
// ABOUTME: Unit tests for MessageContent's off-main-model badge behavior.
// ABOUTME: Covers correct session resolution for top-level and subagent messages.
import { describe, it, expect, vi } from "vitest";
import { mount, unmount, tick } from "svelte";
import type { Message, Session } from "../../api/types.js";

// Mock all stores and child components that MessageContent depends on.
vi.mock("../../stores/sessions.svelte.js", () => ({
sessions: {
sessions: [
{
id: "parent-session",
project: "proj",
machine: "mac",
agent: "copilot",
first_message: "hello",
started_at: "2026-01-01T00:00:00Z",
ended_at: "2026-01-01T00:01:00Z",
message_count: 2,
user_message_count: 1,
total_output_tokens: 0,
peak_context_tokens: 0,
main_model: "claude-sonnet-4.6",
created_at: "2026-01-01T00:00:00Z",
},
],
activeSession: null,
childSessions: new Map(),
},
}));

vi.mock("../../stores/ui.svelte.js", () => ({
ui: {
isBlockVisible: () => true,
findInSession: { query: "" },
},
}));

vi.mock("../../stores/pins.svelte.js", () => ({
pins: { isPinned: () => false, togglePin: async () => {} },
}));

vi.mock("./ThinkingBlock.svelte", () => ({ default: {} }));
vi.mock("./ToolBlock.svelte", () => ({ default: {} }));
vi.mock("./CodeBlock.svelte", () => ({ default: {} }));
vi.mock("./SkillBlock.svelte", () => ({ default: {} }));

// @ts-ignore
import MessageContent from "./MessageContent.svelte";

function makeMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
session_id: "parent-session",
ordinal: 0,
role: "assistant",
content: "Hello from assistant",
has_thinking: false,
has_tool_use: false,
content_length: 20,
timestamp: "2026-01-01T00:00:30Z",
model: "",
...overrides,
};
}

function makeSession(id: string, mainModel: string): Session {
return {
id,
project: "proj",
machine: "mac",
agent: "copilot",
first_message: "hello",
started_at: "2026-01-01T00:00:00Z",
ended_at: "2026-01-01T00:01:00Z",
message_count: 2,
user_message_count: 1,
total_output_tokens: 0,
peak_context_tokens: 0,
main_model: mainModel,
created_at: "2026-01-01T00:00:00Z",
};
}

describe("MessageContent off-main-model badge", () => {
it("shows no model badge when message.model matches the session main_model", async () => {
const message = makeMessage({ model: "claude-sonnet-4.6" });
const component = mount(MessageContent, {
target: document.body,
props: { message },
});
await tick();

expect(document.querySelector(".message-model")).toBeNull();
unmount(component);
});

it("shows model badge when message.model differs from session main_model", async () => {
const message = makeMessage({ model: "claude-haiku-4.5" });
const component = mount(MessageContent, {
target: document.body,
props: { message },
});
await tick();

const badge = document.querySelector(".message-model");
expect(badge).toBeTruthy();
expect(badge?.textContent).toContain("haiku-4.5");
unmount(component);
});

it("shows no model badge when message has no model set", async () => {
const message = makeMessage({ model: "" });
const component = mount(MessageContent, {
target: document.body,
props: { message },
});
await tick();

expect(document.querySelector(".message-model")).toBeNull();
unmount(component);
});

it("shows no model badge for user messages even when model is set", async () => {
const message = makeMessage({ role: "user", model: "claude-haiku-4.5" });
const component = mount(MessageContent, {
target: document.body,
props: { message },
});
await tick();

expect(document.querySelector(".message-model")).toBeNull();
unmount(component);
});

it("uses explicit owningSession prop over sessions.sessions lookup", async () => {
// The message belongs to a child session (haiku as main model) but its
// session_id is "parent-session" in the store (sonnet as main model).
// Passing the child session explicitly should compare against haiku.
const childSession = makeSession("child-session", "claude-haiku-4.5");
const message = makeMessage({
session_id: "child-session",
model: "claude-haiku-4.5", // same as child main_model → no badge
});
const component = mount(MessageContent, {
target: document.body,
props: { message, owningSession: childSession },
});
await tick();

// model matches child session's main_model → no badge
expect(document.querySelector(".message-model")).toBeNull();
unmount(component);
});

it("shows badge for subagent message using a different model than its child session", async () => {
// Child session's main model is haiku, but this message used sonnet.
const childSession = makeSession("child-session", "claude-haiku-4.5");
const message = makeMessage({
session_id: "child-session",
model: "claude-sonnet-4.6",
});
const component = mount(MessageContent, {
target: document.body,
props: { message, owningSession: childSession },
});
await tick();

const badge = document.querySelector(".message-model");
expect(badge).toBeTruthy();
expect(badge?.textContent).toContain("sonnet-4.6");
unmount(component);
});

it("without owningSession prop, falls back to sessions.sessions lookup by session_id", async () => {
// parent-session has main_model=claude-sonnet-4.6 in the mock store.
// Message uses haiku → badge should appear.
const message = makeMessage({
session_id: "parent-session",
model: "claude-haiku-4.5",
});
const component = mount(MessageContent, {
target: document.body,
props: { message },
});
await tick();

const badge = document.querySelector(".message-model");
expect(badge).toBeTruthy();
expect(badge?.textContent).toContain("haiku-4.5");
unmount(component);
});
});
14 changes: 12 additions & 2 deletions frontend/src/lib/components/content/SubagentInline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<script lang="ts">
import type { Message, Session } from "../../api/types.js";
import { getMessages, getSession } from "../../api/client.js";
import { formatTokenCount } from "../../utils/format.js";
import { formatTokenCount, shortModelName } from "../../utils/format.js";
import { sessions } from "../../stores/sessions.svelte.js";
import { router } from "../../stores/router.svelte.js";
import MessageContent from "./MessageContent.svelte";
Expand Down Expand Up @@ -75,6 +75,9 @@
{#if ctxTokens + subagentSession.total_output_tokens > 0}
<span class="toggle-tokens">({formatTokenCount(ctxTokens)} ctx / {formatTokenCount(subagentSession.total_output_tokens)} out)</span>
{/if}
{#if subagentSession.main_model}
<span class="toggle-model" title={subagentSession.main_model}>{shortModelName(subagentSession.main_model)}</span>
{/if}
{/if}
</button>
<a
Expand All @@ -95,7 +98,7 @@
<div class="subagent-status subagent-error">{error}</div>
{:else if messages && messages.length > 0}
{#each messages as message}
<MessageContent {message} isSubagentContext={true} />
<MessageContent {message} isSubagentContext={true} owningSession={subagentSession} />
{/each}
{:else if messages}
<div class="subagent-status">No messages</div>
Expand Down Expand Up @@ -192,6 +195,13 @@
flex-shrink: 0;
}

.toggle-model {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}


.subagent-messages {
border-left: 3px solid var(--accent-green);
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/lib/components/layout/SessionBreadcrumb.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
} from "../../api/client.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { agentColor } from "../../utils/agents.js";
import { formatTokenCount } from "../../utils/format.js";
import { formatTokenCount, shortModelName } from "../../utils/format.js";
import { sessions } from "../../stores/sessions.svelte.js";
import {
supportsResume,
Expand Down Expand Up @@ -447,6 +447,9 @@
{formatTokenCount(sessionContextTokens)} ctx / {formatTokenCount(session?.total_output_tokens ?? 0)} out
</span>
{/if}
{#if session?.main_model}
<span class="model-badge" title={session.main_model}>{shortModelName(session.main_model)}</span>
{/if}
<div class="actions-wrapper">
<button
class="find-btn"
Expand Down Expand Up @@ -703,6 +706,16 @@
flex-shrink: 0;
}

.model-badge {
font-size: 10px;
color: var(--text-muted);
padding: 1px 5px;
border-radius: 4px;
background: var(--bg-tertiary);
white-space: nowrap;
flex-shrink: 0;
}


.actions-wrapper {
position: relative;
Expand Down
Loading
Loading