<script lang="ts">
import type { Session } from "../../api/types.js";
import { placeLabel } from "../../utils/places.js";
import { contextMenu as placeMenu } from "../../utils/menu.js";
import { sessions, isRecentlyActive } from "../../stores/sessions.svelte.js";
import { starred } from "../../stores/starred.svelte.js";
import { notifications } from "../../stores/notifications.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { formatRelativeTime, truncate } from "../../utils/format.js";
import { agentColor as getAgentColor } from "../../utils/agents.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import AppIcon from "../layout/AppIcon.svelte";
interface Props {
session: Session;
continuationCount?: number;
groupSessionIds?: string[];
hideAgent?: boolean;
hideProject?: boolean;
/** Render in compact mode (smaller, used for child sessions). */
compact?: boolean;
/** Whether this item's continuation chain is expanded. */
expanded?: boolean;
/** Callback to toggle continuation chain expand/collapse. */
onToggleExpand?: () => void;
/** Nesting depth: 0 = root, 1 = child, 2 = grandchild. */
depth?: number;
/** Whether this is the last sibling at its depth level. */
isLastChild?: boolean;
/** Whether the group contains subagent children. */
hasSubagents?: boolean;
/** Whether the group contains teammate children. */
hasTeammates?: boolean;
}
let {
session,
continuationCount = 1,
groupSessionIds,
hideAgent = false,
hideProject = false,
compact = false,
expanded = false,
onToggleExpand,
depth = 0,
isLastChild = false,
hasSubagents = false,
hasTeammates = false,
}: Props = $props();
let isActive = $derived.by(() => {
const aid = sessions.activeSessionId;
if (!aid) return false;
// Direct match (child rows, or root with no group).
if (aid === session.id) return true;
// Parent row: only highlight when the chain is collapsed
// (i.e. the child is not visible as its own row).
if (groupSessionIds && !expanded) {
return groupSessionIds.includes(aid);
}
return false;
});
let recentlyActive = $derived(isRecentlyActive(session));
let agentColor = $derived(
getAgentColor(session.agent),
);
let showMachine = $derived(
!compact &&
!!session.machine &&
session.machine !== "local",
);
/** Whether this session is a team member (received a <teammate-message>). */
let isTeamSession = $derived(
session.first_message?.includes("<teammate-message") ?? false,
);
/**
* Clean display name: for teammate sessions, extract the unique task
* description (e.g. "Task #2: Align ROADMAP.md...") instead of the
* repetitive "You are a teammate on..." boilerplate.
*/
let displayName = $derived.by(() => {
if (session.display_name) return truncate(session.display_name, 50);
let msg = session.first_message ?? "";
if (msg.includes("<teammate-message")) {
msg = msg
.replace(/<teammate-message[^>]*>/g, "")
.replace(/<\/teammate-message>/g, "")
.trim();
// Extract "Task #N: description" from the boilerplate.
const taskMatch = msg.match(/Task\s*#?\d+[:\s]+(.+?)(?:\s+\d+\.|$)/s);
if (taskMatch) {
return truncate(taskMatch[1]!.trim(), 50);
}
// Fallback: skip the "You are a teammate on ..." boilerplate.
const afterTeam = msg.match(/team[."]\s*[^.]*?[.]\s+(.+)/s)
?? msg.match(/You are a teammate[^.]*\.\s+(.+)/s);
if (afterTeam) {
return truncate(afterTeam[1]!.trim(), 50);
}
}
return msg ? truncate(msg, 50) : truncate(placeLabel(session), 30);
});
let timeStr = $derived(
formatRelativeTime(session.ended_at ?? session.started_at),
);
let isStarred = $derived(starred.isStarred(session.id));
let childCount = $derived(
continuationCount > 1 ? continuationCount - 1 : 0,
);
let hasChildren = $derived(childCount > 0 && !!onToggleExpand);
/** Whether this is an orphaned teammate showing at root level. */
let isOrphanedTeammate = $derived(
depth === 0 && isTeamSession,
);
function handleStar(e: MouseEvent) {
e.stopPropagation();
starred.toggle(session.id);
}
function handleToggle(e: MouseEvent) {
e.stopPropagation();
onToggleExpand?.();
}
// Context menu state
let contextMenu: { x: number; y: number } | null = $state(null);
// Rename state
let renaming = $state(false);
let renameValue = $state("");
/** What the field opened with: leaving it so is not a rename. */
let renameStartedWith = "";
let renameInput: HTMLInputElement | undefined = $state(undefined);
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY };
}
function closeContextMenu() {
contextMenu = null;
}
function startRename() {
renameValue = session.display_name ?? session.first_message ?? "";
renameStartedWith = renameValue;
renaming = true;
closeContextMenu();
requestAnimationFrame(() => renameInput?.select());
}
async function submitRename() {
if (!renaming) return;
renaming = false;
// Unchanged is not a rename. The field opens on the first message when
// there is no name, and saving that would name the session after it.
if (renameValue.trim() === renameStartedWith.trim()) return;
const name = renameValue.trim() || null;
try {
await sessions.renameSession(session.id, name);
} catch (e) {
notifications.upsert(
"session:rename",
"error",
"Rename failed",
e instanceof Error ? e.message : String(e),
"session",
);
}
}
function handleStarFromMenu() {
starred.toggle(session.id);
closeContextMenu();
}
function handleCopyId() {
void copyToClipboard(session.id).catch(() => {});
closeContextMenu();
}
function handleOpenInNewTab() {
workbench.openSession(session);
closeContextMenu();
}
async function handleDelete() {
closeContextMenu();
try {
await sessions.deleteSession(session.id);
} catch {
// silently fail
}
}
function handleDblClick(e: MouseEvent) {
e.preventDefault();
startRename();
}
$effect(() => {
if (!contextMenu) return;
function handler() {
contextMenu = null;
}
const id = setTimeout(() => {
document.addEventListener("click", handler, { once: true });
document.addEventListener("contextmenu", handler, {
once: true,
});
}, 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", handler);
document.removeEventListener("contextmenu", handler);
};
});
$effect(() => {
if (!contextMenu) return;
function handler(e: KeyboardEvent) {
if (e.key === "Escape") contextMenu = null;
}
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="session-item"
class:active={isActive}
class:compact
class:depth-1={depth === 1}
class:depth-2={depth >= 2}
class:orphaned-teammate={isOrphanedTeammate}
data-session-id={session.id}
role="button"
tabindex="0"
style:padding-left={`var(--wb-tree-indent-${depth})`}
onclick={() => {
sessions.selectSession(session.id);
workbench.openSession(session, true);
}}
ondblclick={(e) => {
if ((e.target as HTMLElement).closest(".session-name")) return;
workbench.openSession(session, false);
}}
onkeydown={(e) => { if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); sessions.selectSession(session.id); workbench.openSession(session, false); } }}
oncontextmenu={handleContextMenu}
>
<!-- Tree expand/collapse or connector -->
{#if hasChildren}
<button
type="button"
class="tree-toggle"
onclick={handleToggle}
tabindex="-1"
aria-label={expanded ? "Collapse" : "Expand"}
>
<AppIcon
class={`tree-arrow${expanded ? " expanded" : ""}`}
name="chevron-right"
size={10}
/>
</button>
{:else if depth > 0}
<span class="tree-dash"></span>
{:else}
<span class="tree-spacer"></span>
{/if}
{#if !hideAgent || recentlyActive}
<span
class="agent-dot"
class:recently-active={recentlyActive}
style:background={agentColor}
></span>
{/if}
<div class="session-info">
{#if renaming}
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={renameInput}
bind:value={renameValue}
class="rename-input"
autofocus
onclick={(e) => e.stopPropagation()}
onblur={submitRename}
onkeydown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
submitRename();
}
if (e.key === "Escape") {
e.stopPropagation();
renaming = false;
}
}}
/>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="session-name" ondblclick={handleDblClick}>{displayName}</div>
{/if}
<div class="session-meta">
{#if !hideProject}
<span class="session-project">{placeLabel(session)}</span>
{/if}
<span class="session-time">{timeStr}</span>
<span class="session-count">{session.user_message_count}</span>
{#if hasSubagents}
<AppIcon class="group-hint-icon" name="user" size={9} />
{/if}
{#if hasTeammates}
<AppIcon class="group-hint-icon" name="users" size={11} />
{/if}
{#if childCount > 0 && !onToggleExpand}
<span class="continuation-badge">x{continuationCount}</span>
{/if}
</div>
</div>
{#if !compact}
<button
class="star-btn"
class:starred={isStarred}
onclick={handleStar}
title={isStarred ? "Unstar session" : "Star session"}
aria-label={isStarred ? "Unstar session" : "Star session"}
>
{#if isStarred}
<AppIcon name="star" size={12} />
{:else}
<AppIcon name="star" size={12} />
{/if}
</button>
{/if}
{#if !compact && (!hideAgent || showMachine)}
<div class="side-meta">
{#if !hideAgent}
<span class="agent-tag" style:color={agentColor}>{session.agent}</span>
{/if}
{#if showMachine}
<span class="machine-tag" title={session.machine}>
{truncate(session.machine, 18)}
</span>
{/if}
</div>
{/if}
</div>
{#if contextMenu}
<div
class="context-menu"
use:placeMenu={contextMenu}
>
<button class="context-menu-item" onclick={handleOpenInNewTab}>Open in Editor</button>
<button class="context-menu-item" onclick={startRename}>Rename</button>
<button class="context-menu-item" onclick={handleStarFromMenu}>
{isStarred ? "Unstar" : "Star"}
</button>
<div class="context-menu-separator"></div>
<button class="context-menu-item" onclick={handleCopyId}>Copy Session ID</button>
<div class="context-menu-separator"></div>
<button class="context-menu-item danger" onclick={handleDelete}>Delete</button>
</div>
{/if}
<style>
.session-item {
display: flex;
align-items: center;
gap: var(--wb-spacing-5);
width: 100%;
height: var(--wb-control-size-42);
padding: 0 var(--wb-spacing-10);
padding-right: var(--wb-spacing-10);
text-align: left;
transition: background 0.1s;
user-select: none;
-webkit-user-select: none;
cursor: pointer;
position: relative;
}
.session-item.compact {
height: var(--wb-control-size-34);
gap: var(--wb-spacing-4);
}
.session-item.depth-1,
.session-item.depth-2 {
background: transparent;
}
.session-item:hover {
background: var(--vscode-list-hoverBackground);
}
.session-item.active {
background: var(--vscode-list-hoverBackground);
}
/* Orphaned teammate at root level — dim it slightly */
.session-item.orphaned-teammate {
opacity: 0.6;
}
/* Tree toggle (▶/▼) */
.tree-toggle {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: var(--wb-control-size-16);
height: 100%;
flex-shrink: 0;
cursor: pointer;
color: var(--vscode-disabledForeground);
transition: color 0.1s;
}
.tree-toggle:hover {
color: var(--vscode-editor-foreground);
}
:global(.tree-arrow) {
transition: transform 150ms ease;
}
:global(.tree-arrow.expanded) {
transform: rotate(90deg);
}
/* Spacer for leaf nodes — same width as toggle to align text */
.tree-dash {
width: var(--wb-control-size-16);
flex-shrink: 0;
}
/* Empty spacer for root items without children */
.tree-spacer {
width: var(--wb-control-size-16);
flex-shrink: 0;
}
.agent-dot {
width: var(--wb-control-size-5);
height: var(--wb-control-size-5);
border-radius: 50%;
flex-shrink: 0;
}
.agent-dot.recently-active {
animation: pulse-glow 3s ease-in-out infinite;
will-change: box-shadow;
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
50% {
box-shadow: 0 0 6px 3px color-mix(
in srgb,
var(--vscode-testing-iconPassed) 40%,
transparent
);
}
}
.side-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--wb-spacing-3);
min-width: 0;
flex-shrink: 0;
margin-left: var(--wb-spacing-4);
}
/* Agent tag on the right side */
.agent-tag {
font-size: var(--wb-font-size-8);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
line-height: 1;
opacity: 0.7;
white-space: nowrap;
max-width: var(--wb-control-size-52);
overflow: hidden;
text-overflow: ellipsis;
}
.machine-tag {
font-size: var(--wb-font-size-9);
line-height: 1;
color: var(--vscode-disabledForeground);
opacity: 0.9;
white-space: nowrap;
max-width: var(--wb-control-size-74);
overflow: hidden;
text-overflow: ellipsis;
}
.session-info {
min-width: 0;
flex: 1;
}
.session-name {
font-size: var(--wb-font-size-12);
font-weight: 450;
color: var(--vscode-editor-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
letter-spacing: -0.005em;
}
.compact .session-name {
font-size: var(--wb-font-size-11);
color: var(--vscode-foreground);
}
.rename-input {
font-size: var(--wb-font-size-12);
font-weight: 450;
color: var(--vscode-editor-foreground);
background: var(--vscode-list-hoverBackground);
border: 1px solid var(--vscode-focusBorder);
border-radius: var(--wb-corner-radius-3);
padding: var(--wb-spacing-1) var(--wb-spacing-4);
width: 100%;
outline: none;
line-height: 1.3;
}
.session-meta {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
line-height: 1.3;
letter-spacing: 0.01em;
}
.compact .session-meta {
font-size: var(--wb-font-size-9);
}
.session-project {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: var(--wb-control-size-100);
}
.session-time {
white-space: nowrap;
flex-shrink: 0;
}
:global(.group-hint-icon) {
flex-shrink: 0;
color: var(--vscode-disabledForeground);
opacity: 0.5;
}
.session-count {
white-space: nowrap;
flex-shrink: 0;
}
.session-count::before {
content: "\2022 ";
}
.continuation-badge {
font-size: var(--wb-font-size-9);
font-weight: 600;
color: var(--vscode-focusBorder);
white-space: nowrap;
flex-shrink: 0;
}
.star-btn {
width: var(--wb-control-size-20);
height: var(--wb-control-size-20);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--wsb-radius-sm);
color: var(--vscode-disabledForeground);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s, color 0.12s, background 0.12s;
}
.session-item:hover .star-btn,
.session-item:focus-within .star-btn,
.star-btn:focus-visible,
.star-btn.starred {
opacity: 1;
}
.star-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-foreground);
}
.star-btn.starred {
color: var(--vscode-editorWarning-foreground);
}
.star-btn.starred:hover {
color: var(--vscode-editorWarning-foreground);
background: var(--vscode-list-hoverBackground);
}
</style>