Skip to content

Commit 49ebfef

Browse files
authored
feat(health-bar): replace instance switcher Popover with Dialog (#142)
## Summary - Replaces the `Popover` + `Command` combo with a proper `Dialog` (native `<dialog showModal()>` via `shared/ui/Dialog`) that opens when clicking the project name trigger - `Command.Input` is always visible in the dialog — the placeholder changes to *"No other instances running"* in the empty state so users know what to do - Extracts the instance list row into `entities/instance/ui/InstanceItem.svelte` (FSD: entity layer owns its own display primitive) - Migrates switcher trigger CSS from `:global(.switcher-btn)` to scoped selectors (it's now a plain `<button>`, not `Popover.Trigger`) - Removes the `:global(.switcher-popover)` block; `Command.Input` gets a box-style border instead of the previous bottom-border-only style ## Why Previous Popover felt like a plain floating box with no backdrop or modal affordance — especially noticeable in the no-instances empty state. A Dialog gives a proper modal surface, keyboard dismiss (Escape), backdrop click to close, and focus trap out of the box. ## Test plan - [ ] Clicking the project name opens a modal dialog with "Switch instance" title - [ ] Escape or clicking the backdrop closes the dialog - [ ] Empty state: Command.Input shows disabled placeholder, empty-state copy is visible - [ ] With other instances: search filters list, clicking an instance triggers pre-flight check → navigate or toast error - [ ] `data-test` attributes preserved: `instance-switcher-trigger`, `instance-switcher`, `instance-switcher-empty`, `instance-item`, `instance-status`, `instance-offline` - [ ] `svelte-check`: 0 errors, 0 warnings (1072 files) Refs: PRD-027 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 9f19da1 + 4617dff commit 49ebfef

4 files changed

Lines changed: 137 additions & 148 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export type { Instance, InstanceScope, InstancesPayload } from "./model/types";
22
export type { InstanceStatus, InstanceHealth } from "./model/instance-status";
33
export { instancePoller } from "./api/store";
4+
export { InstanceItem } from "./ui";
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<script lang="ts">
2+
import type { Instance } from "../model/types";
3+
import type { InstanceStatus } from "../model/instance-status";
4+
5+
interface Props {
6+
instance: Instance;
7+
status: InstanceStatus | null | undefined;
8+
}
9+
10+
let { instance, status }: Props = $props();
11+
</script>
12+
13+
<span class="item">
14+
<span class="item-name">{instance.projectName}</span>
15+
<span class="item-meta">
16+
<span class="item-host">{instance.host}:{instance.port}</span>
17+
{#if status === undefined}
18+
<span class="item-skeleton" aria-hidden="true"></span>
19+
{:else if status !== null}
20+
<span class="item-status" data-test="instance-status">
21+
{status.agentCount} agent{status.agentCount === 1 ? "" : "s"}
22+
· {status.health?.total ?? "?"} artifacts
23+
</span>
24+
{:else}
25+
<span class="item-offline" data-test="instance-offline">offline</span>
26+
{/if}
27+
</span>
28+
</span>
29+
30+
<style>
31+
.item {
32+
display: inline-flex;
33+
flex-direction: column;
34+
gap: 3px;
35+
min-width: 0;
36+
}
37+
.item-name {
38+
display: inline-flex;
39+
align-items: center;
40+
gap: 8px;
41+
color: var(--fg-1);
42+
font-size: 12px;
43+
font-family: var(--font-mono);
44+
}
45+
.item-meta {
46+
display: inline-flex;
47+
align-items: center;
48+
gap: 8px;
49+
}
50+
.item-host {
51+
color: var(--fg-3);
52+
font-family: var(--font-mono);
53+
font-size: 10px;
54+
letter-spacing: 0.04em;
55+
}
56+
.item-skeleton {
57+
display: inline-block;
58+
width: 56px;
59+
height: 9px;
60+
border-radius: 2px;
61+
background: color-mix(in srgb, var(--fg) 10%, transparent);
62+
animation: skeleton-pulse 1.4s ease-in-out infinite;
63+
}
64+
@keyframes skeleton-pulse {
65+
0%, 100% { opacity: 0.3; }
66+
50% { opacity: 0.7; }
67+
}
68+
.item-status {
69+
color: var(--fg-4);
70+
font-family: var(--font-mono);
71+
font-size: 10px;
72+
letter-spacing: 0.02em;
73+
}
74+
.item-offline {
75+
color: var(--bad);
76+
font-family: var(--font-mono);
77+
font-size: 10px;
78+
letter-spacing: 0.06em;
79+
text-transform: uppercase;
80+
}
81+
</style>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as InstanceItem } from './InstanceItem.svelte';

template/src/widgets/health-bar/ui/HealthBar.svelte

Lines changed: 54 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { Command, Popover } from "bits-ui";
2+
import { Command } from "bits-ui";
33
import ChevronsUpDown from "@lucide/svelte/icons/chevrons-up-down";
44
import { healthPoller } from "@/entities/health";
55
import {
@@ -9,11 +9,12 @@
99
} from "@/entities/health/lib/notify.svelte";
1010
import {
1111
instancePoller,
12+
InstanceItem,
1213
type Instance,
1314
type InstanceStatus,
1415
} from "@/entities/instance";
1516
import { themeStore, type ThemeMode } from "@/shared/lib";
16-
import { Toggle, ToggleGroup, ToggleGroupItem, toast } from "@/shared/ui";
17+
import { Dialog, Toggle, ToggleGroup, ToggleGroupItem, toast } from "@/shared/ui";
1718
1819
interface Props {
1920
notify?: boolean;
@@ -101,7 +102,7 @@
101102
return () => clearInterval(timer);
102103
});
103104
104-
// Refresh the instances registry list every time the popover opens (issue #134).
105+
// Refresh the instances registry list every time the dialog opens (issue #134).
105106
$effect(() => {
106107
if (switcherOpen) {
107108
void instancePoller.refresh();
@@ -297,38 +298,37 @@
297298
>
298299
<span class="project-sep" aria-hidden="true">/</span>
299300
<div class="project-switcher">
300-
<Popover.Root bind:open={switcherOpen}>
301-
<Popover.Trigger
302-
class="switcher-btn"
303-
data-test="instance-switcher-trigger"
304-
aria-label="Switch forgeplan-web instance"
305-
title={currentInstance
306-
? `${currentInstance.projectName} (${currentInstance.id})`
307-
: (currentId ?? "")}
308-
>
309-
<span class="switcher-trigger-label">
310-
{currentInstance?.projectName ??
311-
health?.project ??
312-
currentId ??
313-
"instance"}
314-
</span>
315-
<ChevronsUpDown size={13} class="switcher-chevron" />
316-
</Popover.Trigger>
317-
<Popover.Portal>
318-
<Popover.Content
319-
class="switcher-popover"
320-
data-test="instance-switcher"
321-
sideOffset={4}
322-
align="start"
323-
trapFocus={false}
324-
>
301+
<button
302+
class="switcher-btn"
303+
class:open={switcherOpen}
304+
data-test="instance-switcher-trigger"
305+
aria-label="Switch forgeplan-web instance"
306+
aria-haspopup="dialog"
307+
title={currentInstance
308+
? `${currentInstance.projectName} (${currentInstance.id})`
309+
: (currentId ?? "")}
310+
onclick={() => { switcherOpen = true; }}
311+
>
312+
<span class="switcher-trigger-label">
313+
{currentInstance?.projectName ??
314+
health?.project ??
315+
currentId ??
316+
"instance"}
317+
</span>
318+
<ChevronsUpDown size={13} class="switcher-chevron" />
319+
</button>
320+
<Dialog
321+
open={switcherOpen}
322+
title="Switch instance"
323+
width="340px"
324+
onclose={() => { switcherOpen = false; switching = false; }}
325+
>
326+
<div data-test="instance-switcher">
325327
<Command.Root shouldFilter={hasOtherInstances}>
326-
{#if hasOtherInstances}
327-
<Command.Input
328-
placeholder="Switch instance…"
329-
class="switcher-cmd-input"
330-
/>
331-
{/if}
328+
<Command.Input
329+
placeholder={hasOtherInstances ? "Search instances…" : "No other instances running"}
330+
class="switcher-cmd-input"
331+
/>
332332
<Command.List class="switcher-cmd-list">
333333
<Command.Empty
334334
class="switcher-cmd-empty"
@@ -358,43 +358,13 @@
358358
data-test-id={inst.id}
359359
disabled={switching}
360360
>
361-
<span class="switcher-item">
362-
<span class="switcher-item-name">
363-
{inst.projectName}
364-
</span>
365-
<span class="switcher-item-meta">
366-
<span class="switcher-item-host"
367-
>{inst.host}:{inst.port}</span
368-
>
369-
{#if status === undefined}
370-
<span
371-
class="switcher-item-skeleton"
372-
aria-hidden="true"
373-
></span>
374-
{:else if status !== null}
375-
<span
376-
class="switcher-item-status"
377-
data-test="instance-status"
378-
>
379-
{status.agentCount} agent{status.agentCount === 1 ? "" : "s"}
380-
· {status.health?.total ?? "?"} artifacts
381-
</span>
382-
{:else}
383-
<span
384-
class="switcher-item-offline"
385-
data-test="instance-offline"
386-
>offline</span
387-
>
388-
{/if}
389-
</span>
390-
</span>
361+
<InstanceItem instance={inst} {status} />
391362
</Command.Item>
392363
{/each}
393364
</Command.List>
394365
</Command.Root>
395-
</Popover.Content>
396-
</Popover.Portal>
397-
</Popover.Root>
366+
</div>
367+
</Dialog>
398368
</div>
399369
</div>
400370
<div class="stats">
@@ -540,7 +510,7 @@
540510
min-width: 0;
541511
max-width: 240px;
542512
}
543-
.project-switcher :global(.switcher-btn) {
513+
.switcher-btn {
544514
display: inline-flex;
545515
align-items: center;
546516
gap: 4px;
@@ -559,24 +529,24 @@
559529
min-width: 0;
560530
max-width: 240px;
561531
}
562-
.project-switcher :global(.switcher-btn:hover) {
532+
.switcher-btn:hover {
563533
background: color-mix(in srgb, var(--fg) 10%, transparent);
564534
color: var(--fg);
565535
}
566-
.project-switcher :global(.switcher-btn[data-state='open']) {
536+
.switcher-btn.open {
567537
background: color-mix(in srgb, var(--fg) 10%, transparent);
568538
color: var(--fg);
569539
}
570-
.project-switcher :global(.switcher-btn:focus-visible) {
540+
.switcher-btn:focus-visible {
571541
outline: 2px solid var(--accent);
572542
outline-offset: 1px;
573543
}
574-
.project-switcher :global(.switcher-chevron) {
544+
:global(.switcher-chevron) {
575545
flex: 0 0 auto;
576546
color: var(--fg-3);
577547
transition: transform 140ms ease;
578548
}
579-
.project-switcher :global(.switcher-btn[data-state='open'] .switcher-chevron) {
549+
.switcher-btn.open :global(.switcher-chevron) {
580550
color: var(--accent);
581551
transform: rotate(180deg);
582552
}
@@ -588,49 +558,34 @@
588558
white-space: nowrap;
589559
min-width: 0;
590560
}
591-
:global(.switcher-popover) {
592-
z-index: 60;
593-
background: var(--bg-1);
594-
border: 1px solid var(--line-2);
595-
border-radius: 4px;
596-
box-shadow: var(--shadow-card);
597-
padding: 4px;
598-
min-width: 220px;
599-
max-width: 300px;
600-
max-height: min(var(--bits-popover-content-available-height, 320px), 320px);
601-
overflow: hidden;
602-
color: var(--fg-1);
603-
outline: none;
604-
font-family: var(--font-mono);
605-
font-size: 12px;
606-
letter-spacing: 0.02em;
607-
display: flex;
608-
flex-direction: column;
609-
}
610561
:global(.switcher-cmd-input) {
611562
width: 100%;
612-
padding: 5px 8px;
613-
background: transparent;
614-
border: none;
615-
border-bottom: 1px solid var(--line-2);
563+
padding: 6px 8px;
564+
background: var(--bg-2);
565+
border: 1px solid var(--line-2);
566+
border-radius: 3px;
616567
color: var(--fg-1);
617568
font: inherit;
618569
font-size: 11px;
619570
outline: none;
620-
margin-bottom: 4px;
571+
margin-bottom: 8px;
572+
box-sizing: border-box;
621573
}
622574
:global(.switcher-cmd-input::placeholder) {
623575
color: var(--fg-4);
624576
}
577+
:global(.switcher-cmd-input:focus) {
578+
border-color: var(--accent);
579+
}
625580
:global(.switcher-cmd-list) {
626581
display: flex;
627582
flex-direction: column;
628583
gap: 1px;
629584
overflow-y: auto;
630-
flex: 1 1 auto;
585+
max-height: 260px;
631586
}
632587
:global(.switcher-cmd-empty) {
633-
padding: 10px 10px 12px;
588+
padding: 10px 4px 12px;
634589
display: flex;
635590
flex-direction: column;
636591
gap: 5px;
@@ -655,55 +610,6 @@
655610
opacity: 0.5;
656611
pointer-events: none;
657612
}
658-
.switcher-item {
659-
display: inline-flex;
660-
flex-direction: column;
661-
gap: 3px;
662-
min-width: 0;
663-
}
664-
.switcher-item-name {
665-
display: inline-flex;
666-
align-items: center;
667-
gap: 8px;
668-
color: var(--fg-1);
669-
font-size: 12px;
670-
}
671-
.switcher-item-meta {
672-
display: inline-flex;
673-
align-items: center;
674-
gap: 8px;
675-
}
676-
.switcher-item-host {
677-
color: var(--fg-3);
678-
font-family: var(--font-mono);
679-
font-size: 10px;
680-
letter-spacing: 0.04em;
681-
}
682-
.switcher-item-skeleton {
683-
display: inline-block;
684-
width: 56px;
685-
height: 9px;
686-
border-radius: 2px;
687-
background: color-mix(in srgb, var(--fg) 10%, transparent);
688-
animation: switcher-skeleton-pulse 1.4s ease-in-out infinite;
689-
}
690-
@keyframes switcher-skeleton-pulse {
691-
0%, 100% { opacity: 0.3; }
692-
50% { opacity: 0.7; }
693-
}
694-
.switcher-item-status {
695-
color: var(--fg-4);
696-
font-family: var(--font-mono);
697-
font-size: 10px;
698-
letter-spacing: 0.02em;
699-
}
700-
.switcher-item-offline {
701-
color: var(--bad);
702-
font-family: var(--font-mono);
703-
font-size: 10px;
704-
letter-spacing: 0.06em;
705-
text-transform: uppercase;
706-
}
707613
.switcher-empty-title {
708614
font-family: var(--font-mono);
709615
font-size: 11px;

0 commit comments

Comments
 (0)