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
14 changes: 9 additions & 5 deletions src/components/island/Island.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import { FinishWatch } from '../../lib/island/finished.svelte';
import { posed } from '../../lib/island/posed.svelte';
import { PrWatch } from '../../lib/island/pr-watch.svelte';
import { alertPlan } from '../../lib/island/pressure';
import { MemoryWatch } from '../../lib/island/pressure.svelte';
import { QuotaWatch } from '../../lib/island/quotas.svelte';
import { RecapReader } from '../../lib/island/recap.svelte';
Expand Down Expand Up @@ -155,8 +156,10 @@
if (!client || demo) return;
return untrack(() => update.follow(client));
});
/** Where Settings lets the warning show: by default the pill, the island left closed (`alertPlan`). */
const memoryPlan = $derived(alertPlan(companion.prefs.memoryAlert));
/** The warning's cause, not its numbers: a warning calls the island once, not on every read. */
const memoryCause = $derived(memory.warning ? `${memory.warning.reason}:${memory.warning.threadId ?? ''}` : null);
const memoryCause = $derived(memory.warning && memoryPlan.opens ? `${memory.warning.reason}:${memory.warning.threadId ?? ''}` : null);
/** The day's finished threads, read for the tabs that show them, as they show. */
const recap = new RecapReader({ client: () => companion.client, projects: () => companion.projects, leftOut: () => companion.ownThreads });
/** The room between a drop and the island. */
Expand Down Expand Up @@ -305,7 +308,7 @@
return untrack(() => frame.call('alarm'));
});

// The memory guard comes near: the island opens on its card, amber, until it is stopped or set aside.
// The memory guard comes near and Settings asks for the panel: the island opens on its card, amber, until it is stopped or set aside.
$effect(() => {
if (!memoryCause) return;
untrack(() => companion.cue('call'));
Expand All @@ -318,7 +321,7 @@
* requests come first, and nothing shows over a full-screen app or during
* focus: the card waits for them.
*/
const away = new AwayWatch({ rows: () => sessions.rows, pr: () => prs.told, warning: () => memory.warning, stopped: () => memory.stopped });
const away = new AwayWatch({ rows: () => sessions.rows, pr: () => prs.told, warning: () => (memoryPlan.card ? memory.warning : null), stopped: () => (memoryPlan.card ? memory.stopped : null) });
$effect(() => away.start());
const awayQuotas = new QuotaWatch(() => ({ shown: companion.prefs.quotas, hidden: companion.prefs.hiddenQuotas }));
/** A return older than this was told before the island was drawn again. */
Expand All @@ -339,7 +342,8 @@
waiting: pose?.waiting ?? waitingOnUser(input, new Map(), now, 0, false).map(({ threadId, title, kind, since }) => ({ threadId, title, kind, since })),
alarms: pose?.alarms ?? companion.notes.alarms,
quota: pose ? pose.quota : tight && { name: tight.account.name, left: tight.bar.left },
seen: away.seen
seen: away.seen,
memoryOff: !memoryPlan.card
};
}

Expand Down Expand Up @@ -453,7 +457,7 @@
ondrop={drop}
>
{#if frame.size !== 'open'}
<IslandBar {companion} {frame} {move} {sessions} told={prs.told ?? finishes.line} {cheering} {reduced} bind:want={pill} bind:timerInLine />
<IslandBar {companion} {frame} {move} {sessions} told={prs.told ?? finishes.line} memory={memoryPlan.pill ? memory.warning : null} {cheering} {reduced} bind:want={pill} bind:timerInLine />
{:else}
<div class="open" class:raised in:bloom={{ reduced }} out:blurAway={{ reduced }} style:max-height="{OPEN_MAX}px" bind:offsetHeight={measured}>
<Deck {companion} {frame} {bar} {lead} {cheering} {reduced} room={OPEN_MAX - TABS_HEIGHT}>
Expand Down
10 changes: 7 additions & 3 deletions src/components/island/IslandBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
-->
<script lang="ts">
import { untrack } from 'svelte';
import { Gauge, Music, ShieldCheck, Timer, X } from '@lucide/svelte';
import { Gauge, MemoryStick, Music, ShieldCheck, Timer, X } from '@lucide/svelte';
import type { Companion } from '../../lib/companion/companion.svelte';
import { describePermission, threadLabel } from '../../lib/companion/describe';
import { writeCompanionPrefs } from '../../lib/companion/prefs';
Expand All @@ -66,6 +66,7 @@
import { isLive } from '../../lib/island/sessions';
import type { Sessions } from '../../lib/island/sessions.svelte';
import { rowDoing } from '../../lib/island/session-text';
import { pressureRed, type Pressure } from '../../lib/island/pressure';
import { islandStatus } from '../../lib/island/status';
import { fill, strings } from '../../lib/strings';
import CompanionCharacter from '../companion/CompanionCharacter.svelte';
Expand All @@ -77,6 +78,8 @@
sessions: Sessions;
/** A thread that just ended, or a pull request's CI, told while the island peeks for it. */
told: (FinishLine & { threadId?: string }) | null;
/** The memory guard's warning, when the pill tells it (`alertPlan`). */
memory: Pressure | null;
cheering: boolean;
reduced: boolean;
/** The size the pill wants; the island's spring goes there. */
Expand All @@ -85,7 +88,7 @@
timerInLine?: boolean;
}

let { companion, frame, move, sessions, told, cheering, reduced, want = $bindable(), timerInLine = $bindable(false) }: Props = $props();
let { companion, frame, move, sessions, told, memory, cheering, reduced, want = $bindable(), timerInLine = $bindable(false) }: Props = $props();

const copy = strings.island;
const card = strings.island.sessions.card;
Expand Down Expand Up @@ -191,6 +194,7 @@
waiting: asking ? { count: 1, from: origin?.title ?? null } : { count: companion.blocking, from: asker },
told: told && frame.flashing ? { text: told.text, tone: told.tone } : null,
rule: traceLine?.text ?? null,
memory: memory ? { pressure: memory, red: pressureRed(memory) } : null,
rest: live && live.kind !== 'live' ? live : null,
robot: rest === 'robot',
focus: focusRow ? sessionLine(focusRow, (focusRow.projectId && companion.projects.get(focusRow.projectId)) || '') : null,
Expand Down Expand Up @@ -380,7 +384,7 @@
<span class="say roll" in:bloom={{ reduced }} data-tone={view.tone} data-paused={view.paused || undefined} data-testid="island-live">
{#key view.glyph === 'timer' ? `timer\n${view.strong}` : viewText}<span class="line" in:rollIn={{ reduced, back }} out:rollOut={{ reduced, back }}
>{#if view.glyph}<span class="glyph" data-tone={view.glyphTone} data-testid={view.glyph === 'rule' ? 'island-rule-trace' : undefined}
>{#if view.glyph === 'quota'}<Gauge size={14} strokeWidth={2.2} aria-hidden="true" />{:else if view.glyph === 'music'}<Music size={14} strokeWidth={2.2} aria-hidden="true" />{:else if view.glyph === 'timer'}<Timer
>{#if view.glyph === 'quota'}<Gauge size={14} strokeWidth={2.2} aria-hidden="true" />{:else if view.glyph === 'music'}<Music size={14} strokeWidth={2.2} aria-hidden="true" />{:else if view.glyph === 'memory'}<MemoryStick size={14} strokeWidth={2.2} aria-hidden="true" />{:else if view.glyph === 'timer'}<Timer
size={14}
strokeWidth={2.2}
aria-hidden="true"
Expand Down
8 changes: 6 additions & 2 deletions src/components/island/settings/IslandSection.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<!--
Settings, Island: what the companion shows and does around the island:
music and lyrics, the quotas (one switch per subscription), closing and
hiding, sounds, the recap after an absence and the waiting threads' radar.
hiding, sounds, the recap after an absence, the memory alert and the
waiting threads' radar.
-->
<script lang="ts">
import { untrack } from 'svelte';
Expand All @@ -10,7 +11,7 @@
import { fill, strings } from '../../../lib/strings';
import type { CoreLink } from '../../../lib/core.svelte';
import { hudGauges } from '../../../lib/companion/hud';
import type { CompanionPrefs } from '../../../lib/companion/prefs';
import { MEMORY_ALERTS, type CompanionPrefs, type MemoryAlert } from '../../../lib/companion/prefs';
import SettingPick from './SettingPick.svelte';
import SettingSwitch from './SettingSwitch.svelte';

Expand All @@ -25,6 +26,8 @@
.map((count) => ({ id: String(count), label: radarLabel(count), active: count === prefs.radarMinutes }))
);

let alertItems = $derived(MEMORY_ALERTS.map((id) => ({ id, label: copy.memoryAlerts[id], active: id === prefs.memoryAlert })));

/** The gateway's subscriptions, as the companion draws them, to pick the ones it shows. */
let gateway = $derived(gatewayReader(store.endpointUrl ?? 'here'));
let gauges = $derived(hudGauges(gateway.state));
Expand Down Expand Up @@ -67,6 +70,7 @@

<div class="card">
<SettingSwitch label={copy.awayRecap} hint={copy.awayRecapHint} checked={prefs.awayRecap} onchange={(awayRecap) => save({ awayRecap })} testid="companion-away-recap" />
<SettingPick label={copy.memoryAlert} hint={copy.memoryAlertHint} items={alertItems} current={copy.memoryAlerts[prefs.memoryAlert]} onpick={(id) => save({ memoryAlert: id as MemoryAlert })} testid="companion-memory-alert" />
<SettingPick label={copy.radar} hint={copy.radarHint} items={radarItems} current={radarLabel(prefs.radarMinutes)} onpick={(id) => save({ radarMinutes: Number(id) })} testid="companion-radar-minutes" />
</div>

Expand Down
98 changes: 89 additions & 9 deletions src/components/island/views/MemoryCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,69 @@
most, and a button that stops it (`resources.killTree`, as Boite's task
manager does). Ignore sets the warning aside until another conversation is
the cause. Once the guard stopped a process itself, a line says which.

By default (Settings, Memory alert) the card is a slim strip, the pill's
words with the stop and the ignore, and a press on its words shows the full
card; with `panel` it is the full card at once, and with `off` nothing.
-->
<script lang="ts">
import { MemoryStick, X } from '@lucide/svelte';
import { ChevronDown, MemoryStick, X } from '@lucide/svelte';
import { useView } from '../../../lib/island/contract';
import { sizeText, type Pressure } from '../../../lib/island/pressure';
import { pillText, pillView } from '../../../lib/island/pill-view';
import { alertPlan, pressureRed, pressureShare, sizeText, type Pressure } from '../../../lib/island/pressure';
import { fill, strings } from '../../../lib/strings';

const view = useView();
const memory = $derived(view.memory);
const copy = strings.island.memory;
/** Past this share of the limit, the bar turns red. */
const RED_SHARE = 0.95;
const plan = $derived(alertPlan(view.companion.prefs.memoryAlert));

const size = (bytes: number) => sizeText(bytes, copy);
const named = (title: string) => title || copy.agent;
const warning = $derived(memory.warning);
const stopped = $derived(memory.stopped);
const warning = $derived(plan.card ? memory.warning : null);
const stopped = $derived(plan.card ? memory.stopped : null);
/** The strip opened on the full card, until another cause comes. */
let expandedFor = $state<string | null>(null);
const cause = $derived(warning ? `${warning.reason}:${warning.threadId ?? ''}` : null);
/** A stop that failed says why on the full card. */
const full = $derived(plan.card === 'full' || memory.problem !== '' || (cause !== null && expandedFor === cause));
/** The strip's words: the pill's own line. */
const line = $derived(
warning
? pillText(pillView({ waiting: { count: 0, from: null }, told: null, rule: null, memory: { pressure: warning, red: pressureRed(warning) }, rest: null, robot: false, focus: null, working: { count: 0, first: null }, goal: null }))
: ''
);

function title(pressure: Pressure): string {
return pressure.reason === 'thread' ? fill(copy.title.thread, { title: named(pressure.title) }) : copy.title[pressure.reason];
}

/** How full the measured limit is, for the bar; the free memory has none. */
const share = $derived(warning && warning.reason !== 'machine' && warning.limit > 0 ? Math.min(1, warning.used / warning.limit) : null);
const share = $derived(warning ? pressureShare(warning) : null);

function stop(threadId: string) {
const client = view.core.client;
if (client) void memory.stop(client, threadId, (reason) => fill(copy.failed, { reason }));
}
</script>

{#if warning}
<section class="memory" class:red={warning.reason === 'machine' || (share ?? 0) >= RED_SHARE} role="alert" data-testid="island-memory" data-reason={warning.reason}>
{#if warning && !full}
<section class="memory strip" class:red={pressureRed(warning)} role="status" data-testid="island-memory-strip" data-reason={warning.reason}>
<button class="open" title={copy.details} aria-expanded="false" onclick={() => (expandedFor = cause)} data-testid="memory-details">
<span class="icon small" aria-hidden="true"><MemoryStick size={12} strokeWidth={2} /></span>
<span class="said-line">{line}</span>
<ChevronDown size={13} strokeWidth={2} aria-hidden="true" />
</button>
{#if warning.threadId}
{@const threadId = warning.threadId}
<button class="button primary slim" disabled={memory.stopping || !view.core.client} title={copy.stopTitle} onclick={() => stop(threadId)} data-testid="memory-stop">
{memory.stopping ? copy.stopping : copy.stop}
</button>
{/if}
<button class="close" aria-label={copy.ignore} title={copy.ignore} onclick={() => memory.ignore()} data-testid="memory-ignore"><X size={13} strokeWidth={2} /></button>
</section>
{:else if warning}
<section class="memory" class:red={pressureRed(warning)} role="alert" data-testid="island-memory" data-reason={warning.reason}>
<span class="icon" aria-hidden="true"><MemoryStick size={15} strokeWidth={2} /></span>
<div class="body">
<p class="title">{title(warning)}</p>
Expand Down Expand Up @@ -88,6 +118,55 @@
.memory.red {
--tone: var(--island-danger);
}
/* The default: one slim line, the details a press away. */
.memory.strip {
align-items: center;
gap: 6px;
padding: 4px 6px 4px 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--tone) 8%, var(--island-card));
}
.open {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
min-width: 0;
height: 24px;
padding: 0 6px 0 0;
border: none;
border-radius: 999px;
background: none;
color: var(--island-text);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.open :global(svg:last-child) {
flex: none;
color: var(--island-muted);
}
.open:hover :global(svg:last-child) {
color: var(--island-text);
}
.said-line {
flex: 1;
min-width: 0;
overflow: hidden;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon.small {
width: 22px;
height: 22px;
}
.button.slim {
flex: none;
height: 24px;
padding: 0 10px;
}
.memory.quiet {
--tone: var(--island-muted);
align-items: center;
Expand Down Expand Up @@ -206,6 +285,7 @@
color: var(--island-text);
}
.button:focus-visible,
.open:focus-visible,
.close:focus-visible {
outline: 2px solid var(--island-talking);
outline-offset: 1px;
Expand Down
52 changes: 52 additions & 0 deletions src/components/island/views/memory-card.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* The memory warning over the deck. By default it is a slim strip, the full
* card a press away; Settings can ask for the full card at once, or nothing.
*/
import { afterEach, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import type { Companion } from '../../../lib/companion/companion.svelte';
import { DEFAULT_COMPANION_PREFS, type MemoryAlert } from '../../../lib/companion/prefs';
import { viewContext, type ViewContext } from '../../../lib/island/contract';
import { MemoryWatch } from '../../../lib/island/pressure.svelte';
import MemoryCard from './MemoryCard.svelte';

const GB = 1024 * 1024 * 1024;

let shown: { card: Record<string, unknown>; target: HTMLElement } | null = null;

function show(memoryAlert: MemoryAlert = DEFAULT_COMPANION_PREFS.memoryAlert): HTMLElement {
const memory = new MemoryWatch();
memory.pose({ reason: 'machine', threadId: 't-1', title: 'Refonte dashboard', bytes: 0.6 * GB, used: 2.8 * GB, limit: 3.2 * GB });
const target = document.body.appendChild(document.createElement('div'));
const context = { companion: { prefs: { ...DEFAULT_COMPANION_PREFS, memoryAlert } } as unknown as Companion, core: { client: null, reachable: true }, memory } as unknown as ViewContext;
shown = { card: mount(MemoryCard, { target, context: viewContext(context) }), target };
flushSync();
return target;
}

function hide() {
if (!shown) return;
unmount(shown.card);
shown.target.remove();
shown = null;
}

afterEach(hide);

const testid = (target: HTMLElement, id: string) => target.querySelector(`[data-testid="${id}"]`);

test('by default the warning is a slim strip, not the full card, and opens on a press', () => {
const target = show();
expect(testid(target, 'island-memory')).toBeNull();
expect(testid(target, 'island-memory-strip')?.textContent).toContain('2.8 GB');
(testid(target, 'memory-details') as HTMLButtonElement).click();
flushSync();
expect(testid(target, 'island-memory')).not.toBeNull();
expect(testid(target, 'island-memory-strip')).toBeNull();
});

test('the panel setting shows the full card at once, off shows nothing', () => {
expect(testid(show('panel'), 'island-memory')).not.toBeNull();
hide();
expect(show('off').querySelector('section')).toBeNull();
});
3 changes: 3 additions & 0 deletions src/lib/companion/companion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ test('stored preferences are read field by field, a wrong field taking its defau
expect(parseCompanionPrefs({ pillRest: 'timer' }).pillRest).toBe('timer');
expect(parseCompanionPrefs({}).awayRecap).toBe(true);
expect(parseCompanionPrefs({ awayRecap: false }).awayRecap).toBe(false);
expect(parseCompanionPrefs({}).memoryAlert).toBe('pill');
expect(parseCompanionPrefs({ memoryAlert: 'off' }).memoryAlert).toBe('off');
expect(parseCompanionPrefs({ memoryAlert: 'loud' }).memoryAlert).toBe('pill');
window.localStorage.setItem(COMPANION_STORAGE_KEY, '{not json');
expect(readCompanionPrefs()).toEqual(DEFAULT_COMPANION_PREFS);
});
Expand Down
6 changes: 6 additions & 0 deletions src/lib/companion/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export const PILL_RESTS = ['live', 'quota', 'music', 'timer', 'robot'] as const
export type IslandPlace = `${IslandEdge}-${IslandSide}`;
export type IslandEdge = 'top' | 'bottom';
export type IslandSide = 'left' | 'center' | 'right';
/** How the memory guard's warning shows: a line in the pill and a slim one over the deck, the deck opened on its full card, or not at all. */
export type MemoryAlert = 'pill' | 'panel' | 'off';
export const MEMORY_ALERTS = ['pill', 'panel', 'off'] as const satisfies readonly MemoryAlert[];
export const ISLAND_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'] as const satisfies readonly IslandPlace[];
/** The character's centre, in fractions of the screen's work area. */
export interface CompanionSpot { x: number; y: number }
Expand Down Expand Up @@ -84,6 +87,7 @@ export interface CompanionPrefs {
radarMinutes: number;
/** Back at the keyboard after a long absence, the island tells what happened meanwhile (`island/away.ts`). */
awayRecap: boolean;
memoryAlert: MemoryAlert;
screenScope: CompanionScreenScope;
/** The agents that stand as the companion, by id, the leader first; `CREW_MAX` at most. */
agents: string[];
Expand Down Expand Up @@ -122,6 +126,7 @@ export const DEFAULT_COMPANION_PREFS: CompanionPrefs = {
focusOnWork: true,
radarMinutes: 10,
awayRecap: true,
memoryAlert: 'pill',
screenScope: 'all',
agents: [],
crewMade: false,
Expand Down Expand Up @@ -189,6 +194,7 @@ export function parseCompanionPrefs(raw: unknown): CompanionPrefs {
focusOnWork: value.focusOnWork !== false,
radarMinutes: value.radarMinutes === 0 ? 0 : minutes(value.radarMinutes, 240, DEFAULT_COMPANION_PREFS.radarMinutes),
awayRecap: value.awayRecap !== false,
memoryAlert: (MEMORY_ALERTS as readonly unknown[]).includes(value.memoryAlert) ? (value.memoryAlert as MemoryAlert) : 'pill',
screenScope: value.screenScope === 'here' || value.screenScope === 'zone' ? value.screenScope : 'all',
agents: Array.isArray(value.agents) ? [...new Set(value.agents.filter((id): id is string => typeof id === 'string' && id !== ''))].slice(0, CREW_MAX) : [],
crewMade: value.crewMade === true,
Expand Down
Loading
Loading