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
127 changes: 98 additions & 29 deletions frontend/src/lib/components/sidebar/SessionItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
UsersRoundIcon,
} from "../../icons.js";
import StatusDot from "../common/StatusDot.svelte";
import { router } from "../../stores/router.svelte.js";

interface Props {
session: SessionGroupInput;
Expand Down Expand Up @@ -140,6 +141,10 @@

let hasChildren = $derived(childCount > 0 && !!onToggleExpand);

const sessionHref = $derived.by(() =>
router.buildSessionHref(session.id),
);

/** Whether this is an orphaned teammate showing at root level. */
let isOrphanedTeammate = $derived(
depth === 0 && isTeamSession,
Expand Down Expand Up @@ -216,6 +221,40 @@
startRename();
}

function handleSessionClick(e: MouseEvent) {
if (
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
e.button !== 0
) {
return;
}
e.preventDefault();
sessions.selectSession(session.id);
}

function handleRowClick(e: MouseEvent) {
if (
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
e.button !== 0
) {
return;
}
const target = e.target;
if (!(target instanceof Element)) {
return;
}
if (target.closest("a, button, input")) {
return;
}
sessions.selectSession(session.id);
}

$effect(() => {
if (!contextMenu) return;
function handler() {
Expand Down Expand Up @@ -246,6 +285,7 @@

<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="session-item"
class:active={isActive}
Expand All @@ -258,8 +298,16 @@
aria-current={isActive ? "page" : undefined}
tabindex="0"
style:padding-left="{8 + depth * 16}px"
onclick={() => sessions.selectSession(session.id)}
onkeydown={(e) => { if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); sessions.selectSession(session.id); } }}
onclick={handleRowClick}
onkeydown={(e) => {
if (e.target !== e.currentTarget) {
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
sessions.selectSession(session.id);
}
}}
oncontextmenu={handleContextMenu}
>
<!-- Tree expand/collapse or connector -->
Expand Down Expand Up @@ -308,35 +356,40 @@
}}
/>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="session-name"
class:shell={displayLabel.isShell}
ondblclick={handleDblClick}
<a
class="session-info-link"
href={sessionHref}
onclick={handleSessionClick}
>
{#if displayLabel.isShell}
<code>{displayLabel.text}</code>
{:else}
{displayLabel.text}
{/if}
</div>
<div
class="session-name"
class:shell={displayLabel.isShell}
ondblclick={handleDblClick}
>
{#if displayLabel.isShell}
<code>{displayLabel.text}</code>
{:else}
{displayLabel.text}
{/if}
</div>
<div class="session-meta">
{#if !hideProject}
<span class="session-project">{session.project}</span>
{/if}
<span class="session-time">{timeStr}</span>
<span class="session-count">{session.user_message_count}</span>
{#if hasSubagents}
<UserRoundIcon class="group-hint-icon" size="9" strokeWidth="2" aria-hidden="true" />
{/if}
{#if hasTeammates}
<UsersRoundIcon class="group-hint-icon" size="11" strokeWidth="2" aria-hidden="true" />
{/if}
{#if childCount > 0 && !onToggleExpand}
<span class="continuation-badge">x{continuationCount}</span>
{/if}
</div>
</a>
{/if}
<div class="session-meta">
{#if !hideProject}
<span class="session-project">{session.project}</span>
{/if}
<span class="session-time">{timeStr}</span>
<span class="session-count">{session.user_message_count}</span>
{#if hasSubagents}
<UserRoundIcon class="group-hint-icon" size="9" strokeWidth="2" aria-hidden="true" />
{/if}
{#if hasTeammates}
<UsersRoundIcon class="group-hint-icon" size="11" strokeWidth="2" aria-hidden="true" />
{/if}
{#if childCount > 0 && !onToggleExpand}
<span class="continuation-badge">x{continuationCount}</span>
{/if}
</div>
</div>

{#if !compact}
Expand Down Expand Up @@ -377,6 +430,15 @@
<button class="context-menu-item" onclick={startRename}>
Rename
</button>
<button
class="context-menu-item"
onclick={() => {
window.open(sessionHref, "_blank", "noopener");
closeContextMenu();
}}
>
Open in new tab
</button>
<button class="context-menu-item danger" onclick={handleDelete}>
Delete
</button>
Expand Down Expand Up @@ -518,6 +580,13 @@
flex: 1;
}

.session-info-link {
display: block;
color: inherit;
text-decoration: none;
min-width: 0;
}

.session-name {
font-size: 12px;
font-weight: 450;
Expand Down
149 changes: 149 additions & 0 deletions frontend/src/lib/components/sidebar/SessionList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,155 @@ describe("SessionList visible hydration", () => {
expect(load).toHaveBeenCalledTimes(1);
});

it("renders the primary session surface as a native href", async () => {
sessions.sessions = [
makeSession({
id: "native-session",
display_name: "Native link session",
is_index_only: false,
}),
];
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
undefined,
);

component = mount(SessionList, { target: document.body });
await tick();

const link = document.querySelector<HTMLAnchorElement>(
".session-info-link",
);
expect(link).not.toBeNull();
expect(link?.getAttribute("href")).toBe("/sessions/native-session");
});

it("keeps keyboard-style anchor activation on the SPA session path", async () => {
const selectSession = vi
.spyOn(sessions, "selectSession")
.mockImplementation(() => {});
sessions.sessions = [
makeSession({
id: "keyboard-session",
display_name: "Keyboard target",
is_index_only: false,
}),
];
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
undefined,
);

component = mount(SessionList, { target: document.body });
await tick();

const link = document.querySelector<HTMLAnchorElement>(
".session-info-link",
);
expect(link).not.toBeNull();
const click = new MouseEvent("click", {
bubbles: true,
cancelable: true,
detail: 0,
});
link!.dispatchEvent(click);

expect(click.defaultPrevented).toBe(true);
expect(selectSession).toHaveBeenCalledWith("keyboard-session");
});

it("keeps the non-link parts of the row selectable", async () => {
const selectSession = vi
.spyOn(sessions, "selectSession")
.mockImplementation(() => {});
sessions.sessions = [
makeSession({
id: "row-session",
display_name: "Row target",
is_index_only: false,
}),
];
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
undefined,
);

component = mount(SessionList, { target: document.body });
await tick();

const sideMeta = document.querySelector<HTMLElement>(".side-meta");
expect(sideMeta).not.toBeNull();
sideMeta!.click();

expect(selectSession).toHaveBeenCalledWith("row-session");
});

it("keeps button-discoverable rows alongside native session links", async () => {
sessions.sessions = [
makeSession({
id: "button-session",
display_name: "Button target",
is_index_only: false,
}),
];
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
undefined,
);

component = mount(SessionList, { target: document.body });
await tick();

const row = document.querySelector<HTMLElement>(".session-item");
const link = document.querySelector<HTMLAnchorElement>(
".session-info-link",
);
expect(row).not.toBeNull();
expect(row?.getAttribute("role")).toBe("button");
expect(row?.getAttribute("tabindex")).toBe("0");
expect(link).not.toBeNull();
expect(link?.getAttribute("href")).toBe("/sessions/button-session");
});

it("opens the same canonical href from the context menu in a new tab", async () => {
const openSpy = vi
.spyOn(window, "open")
.mockReturnValue(null as unknown as Window);
sessions.sessions = [
makeSession({
id: "native-open-session",
display_name: "Open in new tab target",
is_index_only: false,
}),
];
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
undefined,
);

component = mount(SessionList, { target: document.body });
await tick();

const row = document.querySelector<HTMLElement>(".session-item");
expect(row).not.toBeNull();
row!.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 7,
clientY: 8,
}),
);
await tick();

const openInNewTab = Array.from(
document.querySelectorAll<HTMLButtonElement>(".context-menu-item"),
).find((button) => button.textContent === "Open in new tab");
expect(openInNewTab).not.toBeNull();
openInNewTab!.click();

expect(openSpy).toHaveBeenCalledWith(
"/sessions/native-open-session",
"_blank",
"noopener",
);
});

it("uses is_teammate for the collapsed group teammate hint", async () => {
sessions.sessions = [
makeSession({ id: "root", display_name: "Root", is_index_only: true }),
Expand Down