Skip to content

Commit 84d55aa

Browse files
authored
fix(sidebar): add native session links (#460) (#755)
## Summary - Kept `.session-item` as the row container and moved the primary session surface to a native anchor using `router.buildSessionHref(session.id)`. - Kept row-level button discoverability and keyboard activation on the SPA session-selection path, while restoring normal row selection for the visible non-link parts of the sidebar item. - Preserved nested controls (tree toggle, rename flow, star button, side meta) by not converting the whole row into a link. - Added explicit `Open in new tab` to the sidebar session context menu, using the same canonical URL and `window.open(..., "_blank", "noopener")`. ## Scope - Limited to `frontend/src/lib/components/sidebar/SessionItem.svelte` and `frontend/src/lib/components/sidebar/SessionList.test.ts`. - Keeps the outer row container and existing nested controls intact; broader session-entry surfaces stay out of scope for this slice. ## Review Notes - No full-app navigation is added in this slice; both the anchor activation path and the row overflow click surface keep SPA state semantics via `sessions.selectSession(session.id)` while still exposing the canonical href for native link behaviors. - The new context-menu action reuses `sessionHref` derived from the same helper used by the anchor. Fixes #460 Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
1 parent 5d8bfee commit 84d55aa

2 files changed

Lines changed: 247 additions & 29 deletions

File tree

frontend/src/lib/components/sidebar/SessionItem.svelte

Lines changed: 98 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
UsersRoundIcon,
1919
} from "../../icons.js";
2020
import StatusDot from "../common/StatusDot.svelte";
21+
import { router } from "../../stores/router.svelte.js";
2122
2223
interface Props {
2324
session: SessionGroupInput;
@@ -140,6 +141,10 @@
140141
141142
let hasChildren = $derived(childCount > 0 && !!onToggleExpand);
142143
144+
const sessionHref = $derived.by(() =>
145+
router.buildSessionHref(session.id),
146+
);
147+
143148
/** Whether this is an orphaned teammate showing at root level. */
144149
let isOrphanedTeammate = $derived(
145150
depth === 0 && isTeamSession,
@@ -216,6 +221,40 @@
216221
startRename();
217222
}
218223
224+
function handleSessionClick(e: MouseEvent) {
225+
if (
226+
e.metaKey ||
227+
e.ctrlKey ||
228+
e.shiftKey ||
229+
e.altKey ||
230+
e.button !== 0
231+
) {
232+
return;
233+
}
234+
e.preventDefault();
235+
sessions.selectSession(session.id);
236+
}
237+
238+
function handleRowClick(e: MouseEvent) {
239+
if (
240+
e.metaKey ||
241+
e.ctrlKey ||
242+
e.shiftKey ||
243+
e.altKey ||
244+
e.button !== 0
245+
) {
246+
return;
247+
}
248+
const target = e.target;
249+
if (!(target instanceof Element)) {
250+
return;
251+
}
252+
if (target.closest("a, button, input")) {
253+
return;
254+
}
255+
sessions.selectSession(session.id);
256+
}
257+
219258
$effect(() => {
220259
if (!contextMenu) return;
221260
function handler() {
@@ -246,6 +285,7 @@
246285

247286
<!-- svelte-ignore a11y_no_static_element_interactions -->
248287
<!-- svelte-ignore a11y_no_static_element_interactions -->
288+
<!-- svelte-ignore a11y_click_events_have_key_events -->
249289
<div
250290
class="session-item"
251291
class:active={isActive}
@@ -258,8 +298,16 @@
258298
aria-current={isActive ? "page" : undefined}
259299
tabindex="0"
260300
style:padding-left="{8 + depth * 16}px"
261-
onclick={() => sessions.selectSession(session.id)}
262-
onkeydown={(e) => { if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); sessions.selectSession(session.id); } }}
301+
onclick={handleRowClick}
302+
onkeydown={(e) => {
303+
if (e.target !== e.currentTarget) {
304+
return;
305+
}
306+
if (e.key === "Enter" || e.key === " ") {
307+
e.preventDefault();
308+
sessions.selectSession(session.id);
309+
}
310+
}}
263311
oncontextmenu={handleContextMenu}
264312
>
265313
<!-- Tree expand/collapse or connector -->
@@ -308,35 +356,40 @@
308356
}}
309357
/>
310358
{:else}
311-
<!-- svelte-ignore a11y_no_static_element_interactions -->
312-
<div
313-
class="session-name"
314-
class:shell={displayLabel.isShell}
315-
ondblclick={handleDblClick}
359+
<a
360+
class="session-info-link"
361+
href={sessionHref}
362+
onclick={handleSessionClick}
316363
>
317-
{#if displayLabel.isShell}
318-
<code>{displayLabel.text}</code>
319-
{:else}
320-
{displayLabel.text}
321-
{/if}
322-
</div>
364+
<div
365+
class="session-name"
366+
class:shell={displayLabel.isShell}
367+
ondblclick={handleDblClick}
368+
>
369+
{#if displayLabel.isShell}
370+
<code>{displayLabel.text}</code>
371+
{:else}
372+
{displayLabel.text}
373+
{/if}
374+
</div>
375+
<div class="session-meta">
376+
{#if !hideProject}
377+
<span class="session-project">{session.project}</span>
378+
{/if}
379+
<span class="session-time">{timeStr}</span>
380+
<span class="session-count">{session.user_message_count}</span>
381+
{#if hasSubagents}
382+
<UserRoundIcon class="group-hint-icon" size="9" strokeWidth="2" aria-hidden="true" />
383+
{/if}
384+
{#if hasTeammates}
385+
<UsersRoundIcon class="group-hint-icon" size="11" strokeWidth="2" aria-hidden="true" />
386+
{/if}
387+
{#if childCount > 0 && !onToggleExpand}
388+
<span class="continuation-badge">x{continuationCount}</span>
389+
{/if}
390+
</div>
391+
</a>
323392
{/if}
324-
<div class="session-meta">
325-
{#if !hideProject}
326-
<span class="session-project">{session.project}</span>
327-
{/if}
328-
<span class="session-time">{timeStr}</span>
329-
<span class="session-count">{session.user_message_count}</span>
330-
{#if hasSubagents}
331-
<UserRoundIcon class="group-hint-icon" size="9" strokeWidth="2" aria-hidden="true" />
332-
{/if}
333-
{#if hasTeammates}
334-
<UsersRoundIcon class="group-hint-icon" size="11" strokeWidth="2" aria-hidden="true" />
335-
{/if}
336-
{#if childCount > 0 && !onToggleExpand}
337-
<span class="continuation-badge">x{continuationCount}</span>
338-
{/if}
339-
</div>
340393
</div>
341394

342395
{#if !compact}
@@ -377,6 +430,15 @@
377430
<button class="context-menu-item" onclick={startRename}>
378431
Rename
379432
</button>
433+
<button
434+
class="context-menu-item"
435+
onclick={() => {
436+
window.open(sessionHref, "_blank", "noopener");
437+
closeContextMenu();
438+
}}
439+
>
440+
Open in new tab
441+
</button>
380442
<button class="context-menu-item danger" onclick={handleDelete}>
381443
Delete
382444
</button>
@@ -518,6 +580,13 @@
518580
flex: 1;
519581
}
520582
583+
.session-info-link {
584+
display: block;
585+
color: inherit;
586+
text-decoration: none;
587+
min-width: 0;
588+
}
589+
521590
.session-name {
522591
font-size: 12px;
523592
font-weight: 450;

frontend/src/lib/components/sidebar/SessionList.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,155 @@ describe("SessionList visible hydration", () => {
421421
expect(load).toHaveBeenCalledTimes(1);
422422
});
423423

424+
it("renders the primary session surface as a native href", async () => {
425+
sessions.sessions = [
426+
makeSession({
427+
id: "native-session",
428+
display_name: "Native link session",
429+
is_index_only: false,
430+
}),
431+
];
432+
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
433+
undefined,
434+
);
435+
436+
component = mount(SessionList, { target: document.body });
437+
await tick();
438+
439+
const link = document.querySelector<HTMLAnchorElement>(
440+
".session-info-link",
441+
);
442+
expect(link).not.toBeNull();
443+
expect(link?.getAttribute("href")).toBe("/sessions/native-session");
444+
});
445+
446+
it("keeps keyboard-style anchor activation on the SPA session path", async () => {
447+
const selectSession = vi
448+
.spyOn(sessions, "selectSession")
449+
.mockImplementation(() => {});
450+
sessions.sessions = [
451+
makeSession({
452+
id: "keyboard-session",
453+
display_name: "Keyboard target",
454+
is_index_only: false,
455+
}),
456+
];
457+
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
458+
undefined,
459+
);
460+
461+
component = mount(SessionList, { target: document.body });
462+
await tick();
463+
464+
const link = document.querySelector<HTMLAnchorElement>(
465+
".session-info-link",
466+
);
467+
expect(link).not.toBeNull();
468+
const click = new MouseEvent("click", {
469+
bubbles: true,
470+
cancelable: true,
471+
detail: 0,
472+
});
473+
link!.dispatchEvent(click);
474+
475+
expect(click.defaultPrevented).toBe(true);
476+
expect(selectSession).toHaveBeenCalledWith("keyboard-session");
477+
});
478+
479+
it("keeps the non-link parts of the row selectable", async () => {
480+
const selectSession = vi
481+
.spyOn(sessions, "selectSession")
482+
.mockImplementation(() => {});
483+
sessions.sessions = [
484+
makeSession({
485+
id: "row-session",
486+
display_name: "Row target",
487+
is_index_only: false,
488+
}),
489+
];
490+
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
491+
undefined,
492+
);
493+
494+
component = mount(SessionList, { target: document.body });
495+
await tick();
496+
497+
const sideMeta = document.querySelector<HTMLElement>(".side-meta");
498+
expect(sideMeta).not.toBeNull();
499+
sideMeta!.click();
500+
501+
expect(selectSession).toHaveBeenCalledWith("row-session");
502+
});
503+
504+
it("keeps button-discoverable rows alongside native session links", async () => {
505+
sessions.sessions = [
506+
makeSession({
507+
id: "button-session",
508+
display_name: "Button target",
509+
is_index_only: false,
510+
}),
511+
];
512+
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
513+
undefined,
514+
);
515+
516+
component = mount(SessionList, { target: document.body });
517+
await tick();
518+
519+
const row = document.querySelector<HTMLElement>(".session-item");
520+
const link = document.querySelector<HTMLAnchorElement>(
521+
".session-info-link",
522+
);
523+
expect(row).not.toBeNull();
524+
expect(row?.getAttribute("role")).toBe("button");
525+
expect(row?.getAttribute("tabindex")).toBe("0");
526+
expect(link).not.toBeNull();
527+
expect(link?.getAttribute("href")).toBe("/sessions/button-session");
528+
});
529+
530+
it("opens the same canonical href from the context menu in a new tab", async () => {
531+
const openSpy = vi
532+
.spyOn(window, "open")
533+
.mockReturnValue(null as unknown as Window);
534+
sessions.sessions = [
535+
makeSession({
536+
id: "native-open-session",
537+
display_name: "Open in new tab target",
538+
is_index_only: false,
539+
}),
540+
];
541+
vi.spyOn(sessions, "hydrateVisibleSessions").mockResolvedValue(
542+
undefined,
543+
);
544+
545+
component = mount(SessionList, { target: document.body });
546+
await tick();
547+
548+
const row = document.querySelector<HTMLElement>(".session-item");
549+
expect(row).not.toBeNull();
550+
row!.dispatchEvent(
551+
new MouseEvent("contextmenu", {
552+
bubbles: true,
553+
cancelable: true,
554+
clientX: 7,
555+
clientY: 8,
556+
}),
557+
);
558+
await tick();
559+
560+
const openInNewTab = Array.from(
561+
document.querySelectorAll<HTMLButtonElement>(".context-menu-item"),
562+
).find((button) => button.textContent === "Open in new tab");
563+
expect(openInNewTab).not.toBeNull();
564+
openInNewTab!.click();
565+
566+
expect(openSpy).toHaveBeenCalledWith(
567+
"/sessions/native-open-session",
568+
"_blank",
569+
"noopener",
570+
);
571+
});
572+
424573
it("uses is_teammate for the collapsed group teammate hint", async () => {
425574
sessions.sessions = [
426575
makeSession({ id: "root", display_name: "Root", is_index_only: true }),

0 commit comments

Comments
 (0)