Compare commits
43 changed files with 17 additions and 2697 deletions
|
|
@ -1 +1 @@
|
||||||
sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM=
|
sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=
|
||||||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -991,11 +991,7 @@
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"marked": "18.0.6",
|
"marked": "18.0.6"
|
||||||
"preact": "10.29.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "7.0.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/dashboard": {
|
"packages/dashboard": {
|
||||||
|
|
|
||||||
|
|
@ -47,25 +47,6 @@ await build({
|
||||||
loader: { '.css': 'text' },
|
loader: { '.css': 'text' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// The Preact rewrite (in progress — see main.tsx's file comment). A
|
|
||||||
// third, separate bundle rather than folding into the `app.js` build
|
|
||||||
// above: different loader needs (`.css: 'css'`, real stylesheets a
|
|
||||||
// component imports, vs. `app.js`'s raw-text shadow-DOM CSS) and JSX
|
|
||||||
// transpilation, same split swarm-ui's own build already makes.
|
|
||||||
await build({
|
|
||||||
entryPoints: [src('main.tsx')],
|
|
||||||
outfile: staticDir('main.js'),
|
|
||||||
bundle: true,
|
|
||||||
format: 'esm',
|
|
||||||
platform: 'browser',
|
|
||||||
target: ['es2022'],
|
|
||||||
sourcemap: true,
|
|
||||||
logLevel: 'info',
|
|
||||||
jsx: 'automatic',
|
|
||||||
jsxImportSource: 'preact',
|
|
||||||
loader: { '.css': 'css' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
|
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
|
||||||
// (the theme swap contract — its own output file so a swap replaces only
|
// (the theme swap contract — its own output file so a swap replaces only
|
||||||
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
|
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,12 @@
|
||||||
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
|
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ./build.mjs",
|
"build": "node ./build.mjs"
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"marked": "18.0.6",
|
"marked": "18.0.6"
|
||||||
"preact": "10.29.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "7.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
|
|
||||||
// to the real `/api/state` snapshot via `useAgentState`. Covers
|
|
||||||
// everything `app.js` did, including the `needs_login_idle`/
|
|
||||||
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
|
|
||||||
// no credentials has no other web-UI path back online, so this isn't
|
|
||||||
// optional polish.
|
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
||||||
import type { BadgeTone } from '@hive/shared/badge.js';
|
|
||||||
import { Header } from './components/Header.js';
|
|
||||||
import { MetaNav } from './components/MetaNav.js';
|
|
||||||
import { StatusChips } from './components/StatusChips.js';
|
|
||||||
import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js';
|
|
||||||
import { HeaderPill } from './components/HeaderPill.js';
|
|
||||||
import { SidePanel } from './components/SidePanel.js';
|
|
||||||
import { InboxPanel } from './components/InboxPanel.js';
|
|
||||||
import { TodosPanel } from './components/TodosPanel.js';
|
|
||||||
import { TermInput } from './components/TermInput.js';
|
|
||||||
import { OverflowMenu } from './components/OverflowMenu.js';
|
|
||||||
import { LoginFlow } from './components/LoginFlow.js';
|
|
||||||
import { useAgentState } from './hooks/useAgentState.js';
|
|
||||||
import { useTodos } from './hooks/useTodos.js';
|
|
||||||
import { fmtAge, fmtTokens } from './lib/format.js';
|
|
||||||
import { resolveDashboardBase } from './lib/dashboardBase.js';
|
|
||||||
import { submitPauseResume } from './lib/pauseAction.js';
|
|
||||||
import { postModel, postEffort } from './lib/modelEffort.js';
|
|
||||||
import type { TokenUsage } from './types.js';
|
|
||||||
|
|
||||||
type OpenPanel = 'inbox' | 'todos' | null;
|
|
||||||
|
|
||||||
const ALIVE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
|
|
||||||
online: { glyph: '●', text: 'alive', tone: 'positive' },
|
|
||||||
rate_limited: { glyph: '⊘', text: 'rate limited', tone: 'warning' },
|
|
||||||
needs_login_idle: { glyph: '◌', text: 'needs login', tone: 'warning' },
|
|
||||||
needs_login_in_progress: { glyph: '◌', text: 'logging in', tone: 'warning' },
|
|
||||||
};
|
|
||||||
const STATE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
|
|
||||||
idle: { glyph: '💤', text: 'idle', tone: 'neutral' },
|
|
||||||
thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' },
|
|
||||||
compacting: { glyph: '📦', text: 'compacting', tone: 'warning' },
|
|
||||||
};
|
|
||||||
const STATE_TOOLTIPS: Record<string, string> = {
|
|
||||||
offline: 'harness unreachable or claude not logged in',
|
|
||||||
idle: 'turn loop running, no claude invocation in flight',
|
|
||||||
thinking: 'claude is executing the current turn',
|
|
||||||
compacting: "operator-triggered /compact running on the persistent session",
|
|
||||||
};
|
|
||||||
|
|
||||||
// `total` here matches app.js's `renderOneUsage` exactly: input + both
|
|
||||||
// cache buckets, output_tokens deliberately excluded (it's shown in the
|
|
||||||
// title breakdown, not folded into the headline number).
|
|
||||||
function tokenTotal(u: TokenUsage | null): number | null {
|
|
||||||
if (!u) return null;
|
|
||||||
return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Root() {
|
|
||||||
const { state, refresh } = useAgentState();
|
|
||||||
const { todos, refresh: refreshTodos } = useTodos();
|
|
||||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
|
||||||
const liveStreamRef = useRef<LiveStreamHandle>(null);
|
|
||||||
|
|
||||||
// Browser tab title — ports app.js's setHeader logic verbatim: prefer
|
|
||||||
// the human display name + hive_name ("iris // pr1ma") so tabs read
|
|
||||||
// naturally; fall back to the qualified domain label for multi-hive
|
|
||||||
// disambiguation when hive_name is unset, then to plain label for
|
|
||||||
// single-hive deploys. Without this every agent's tab reads the
|
|
||||||
// static index.html <title> forever — can't tell tabs apart with
|
|
||||||
// several open.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state) return;
|
|
||||||
const tab =
|
|
||||||
state.qualified_label && state.qualified_label !== state.label
|
|
||||||
? state.qualified_label
|
|
||||||
: state.label;
|
|
||||||
document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`;
|
|
||||||
}, [state?.label, state?.qualified_label, state?.hive_name]);
|
|
||||||
const termInput = (
|
|
||||||
<footer class="agent-composer">
|
|
||||||
<TermInput
|
|
||||||
label={state?.label ?? '…'}
|
|
||||||
online={state?.status === 'online'}
|
|
||||||
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
|
|
||||||
onClear={() => liveStreamRef.current?.clear()}
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
|
|
||||||
const pills = (
|
|
||||||
<>
|
|
||||||
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
|
|
||||||
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
|
|
||||||
{/* Needs a loaded snapshot for their data (links/forge_public_url,
|
|
||||||
* dashboard_port) — both omitted (like the old page's
|
|
||||||
* populateOverflowMenu) until the first /api/state resolves. */}
|
|
||||||
{state ? <MetaNav links={state.links} forgePublicUrl={state.forge_public_url} /> : null}
|
|
||||||
{state ? <OverflowMenu label={state.label} dashboardBase={resolveDashboardBase(state.dashboard_port)} /> : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
// Kept mounted regardless of `openPanel` (open/closed toggles just the
|
|
||||||
// `open` prop) rather than conditionally rendering the whole
|
|
||||||
// component — SidePanel's slide/fade CSS transitions only fire on a
|
|
||||||
// class change after mount, not on an already-open initial render, so
|
|
||||||
// mount-on-open would skip both the open AND the close animation.
|
|
||||||
const panelTitle = openPanel === 'inbox' ? `inbox · ${state?.inbox.length ?? 0}` : openPanel === 'todos' ? `todos · ${todos.length}` : '';
|
|
||||||
const panel = (
|
|
||||||
<SidePanel open={openPanel !== null} title={panelTitle} onClose={() => setOpenPanel(null)}>
|
|
||||||
{openPanel === 'inbox' ? (
|
|
||||||
<InboxPanel
|
|
||||||
rows={state?.inbox ?? []}
|
|
||||||
label={state?.label ?? ''}
|
|
||||||
dashboardBase={state ? resolveDashboardBase(state.dashboard_port) : ''}
|
|
||||||
onCleared={refresh}
|
|
||||||
/>
|
|
||||||
) : openPanel === 'todos' ? (
|
|
||||||
<TodosPanel todos={todos} onCleared={refreshTodos} />
|
|
||||||
) : null}
|
|
||||||
</SidePanel>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header label="…" pills={pills}>
|
|
||||||
<StatusChips
|
|
||||||
aliveLabel="… connecting"
|
|
||||||
aliveTone="neutral"
|
|
||||||
stateLabel="… booting"
|
|
||||||
stateTone="neutral"
|
|
||||||
model=""
|
|
||||||
availableModels={[]}
|
|
||||||
onSelectModel={() => {}}
|
|
||||||
effort=""
|
|
||||||
availableEfforts={[]}
|
|
||||||
onSelectEffort={() => {}}
|
|
||||||
paused={false}
|
|
||||||
onTogglePause={() => {}}
|
|
||||||
/>
|
|
||||||
</Header>
|
|
||||||
<main className="agent-main">
|
|
||||||
<LiveStream ref={liveStreamRef} />
|
|
||||||
</main>
|
|
||||||
{termInput}
|
|
||||||
{panel}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alive = ALIVE_LABELS[state.status] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
|
||||||
// Mirrors app.js's refreshState: any non-'online' status forces the
|
|
||||||
// turn-state badge to 'offline' regardless of the server's last-known
|
|
||||||
// turn_state (login/rate-limit takes visual priority over a stale
|
|
||||||
// idle/thinking reading from before the harness went offline).
|
|
||||||
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline';
|
|
||||||
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
|
||||||
const stateAge = fmtAge(Date.now() - state.turn_state_since * 1000);
|
|
||||||
const ctx = tokenTotal(state.ctx_usage);
|
|
||||||
const cost = tokenTotal(state.cost_usage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header
|
|
||||||
label={state.label}
|
|
||||||
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}
|
|
||||||
pills={pills}
|
|
||||||
>
|
|
||||||
<StatusChips
|
|
||||||
aliveLabel={`${alive.glyph} ${alive.text}`}
|
|
||||||
aliveTone={alive.tone}
|
|
||||||
stateLabel={`${turnDef.glyph} ${turnDef.text} · ${stateAge}`}
|
|
||||||
stateTone={turnDef.tone}
|
|
||||||
stateTooltip={STATE_TOOLTIPS[effectiveTurnState]}
|
|
||||||
model={state.model}
|
|
||||||
availableModels={state.available_models}
|
|
||||||
onSelectModel={(name) => {
|
|
||||||
postModel(name).then(() => refresh());
|
|
||||||
}}
|
|
||||||
effort={state.effort}
|
|
||||||
availableEfforts={state.available_efforts}
|
|
||||||
onSelectEffort={(level) => {
|
|
||||||
postEffort(level).then(() => refresh());
|
|
||||||
}}
|
|
||||||
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
|
|
||||||
costLabel={cost !== null ? fmtTokens(cost) : undefined}
|
|
||||||
paused={state.paused}
|
|
||||||
onTogglePause={() => {
|
|
||||||
submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Header>
|
|
||||||
<main className="agent-main">
|
|
||||||
{state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? (
|
|
||||||
<LoginFlow status={state.status} session={state.session} onRefresh={refresh} />
|
|
||||||
) : null}
|
|
||||||
<LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} />
|
|
||||||
</main>
|
|
||||||
{termInput}
|
|
||||||
{panel}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -129,30 +129,15 @@ body.agent-shell {
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right cluster — flyout pills, and (new Preact page only) the status
|
/* Right cluster — flyout pills stacked / inline with the overflow
|
||||||
badges (`StatusChips`) too — mara, live: "move badges to where the
|
trigger. Vertically centred against the full-height icon, no
|
||||||
dropdowns are". `flex-wrap: wrap` alone doesn't help if this
|
wrap; pills can drop to a row of their own under crowding via
|
||||||
container can't itself shrink: the old `flex-shrink: 0` here (fine
|
the flex-wrap of `.agent-header-pills` itself. */
|
||||||
for app.js's 2-3-small-pills case, which never needed to shrink)
|
|
||||||
told the flex layout "give this its full natural width no matter
|
|
||||||
what," so once badges made that natural width wider than the
|
|
||||||
viewport, it just overflowed past the edge instead of ever getting
|
|
||||||
narrow enough to trigger its own wrap — mara, live, on the 500px
|
|
||||||
screenshot: "does not overflow properly". Dropping `flex-shrink: 0`
|
|
||||||
(default is `1`) is the whole fix — deliberately NOT pairing it
|
|
||||||
with `min-width: 0` (tried that first, made it worse: an unset
|
|
||||||
`min-width` still floors shrinking at this container's own
|
|
||||||
min-content — the widest single wrapped child, e.g. one badge, not
|
|
||||||
the full unwrapped row — which is the correct floor; `min-width: 0`
|
|
||||||
removes that floor entirely, so at a narrow enough viewport the
|
|
||||||
container shrinks past what its own content needs and the content
|
|
||||||
overflows its box sideways, landing on top of `.agent-header-main`
|
|
||||||
instead of wrapping cleanly — confirmed live, reproducible at
|
|
||||||
320px, not a one-off capture glitch). */
|
|
||||||
.agent-header-pills {
|
.agent-header-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
flex-shrink: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|
@ -372,16 +357,10 @@ h2, h3 {
|
||||||
|
|
||||||
/* Login flow overlay: only rendered when status != online. Sits
|
/* Login flow overlay: only rendered when status != online. Sits
|
||||||
centred over the (likely-empty) terminal area; doesn't take chrome
|
centred over the (likely-empty) terminal area; doesn't take chrome
|
||||||
space in the normal online flow. `--agent-header-real-h` (set by
|
space in the normal online flow. */
|
||||||
the new Preact page's `Header.tsx` via ResizeObserver, falling back
|
|
||||||
to the static `--agent-header-h` guess before the first
|
|
||||||
measurement / on the old app.js page, which never sets it) is the
|
|
||||||
header's ACTUAL rendered height, not an assumed one — see
|
|
||||||
Header.tsx's file comment for why this matters (a static guess is
|
|
||||||
wrong the moment the header wraps an extra line on some viewport). */
|
|
||||||
.agent-status-overlay {
|
.agent-status-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 1.5em);
|
top: calc(var(--agent-header-h) + 1.5em);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
max-width: 44em;
|
max-width: 44em;
|
||||||
|
|
@ -737,9 +716,9 @@ pre.diff {
|
||||||
and last rows reachable with extra padding inside the scroll
|
and last rows reachable with extra padding inside the scroll
|
||||||
area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill,
|
area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill,
|
||||||
focus restore) clear of the floats too. */
|
focus restore) clear of the floats too. */
|
||||||
padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
|
padding-top: calc(var(--agent-header-h) + 0.8em);
|
||||||
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||||
scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
|
scroll-padding-top: calc(var(--agent-header-h) + 0.8em);
|
||||||
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -760,21 +739,7 @@ pre.diff {
|
||||||
rules below stay scoped to whichever ancestor owns it. */
|
rules below stay scoped to whichever ancestor owns it. */
|
||||||
.term-input .sendform-term {
|
.term-input .sendform-term {
|
||||||
display: flex;
|
display: flex;
|
||||||
/* mara, live: "bottom input row text not aligned / inconsistent
|
align-items: flex-start;
|
||||||
sizes." Real cause: `.prompt` (1em, no line-height override) and
|
|
||||||
`.submit-hint` (0.8em, no line-height override) each get a
|
|
||||||
browser-default line-box at a different em-relative size, so
|
|
||||||
`align-items: flex-start` (their old value) pinned each one's
|
|
||||||
line-box top edge to the row's top — same nominal `padding-top`
|
|
||||||
but a DIFFERENT absolute offset per element, since 0.25em means
|
|
||||||
something different at 1em vs 0.8em font-size. `center` sidesteps
|
|
||||||
the whole "line-box for text this size" mismatch by aligning each
|
|
||||||
item's own box middle instead of a top edge that was never
|
|
||||||
actually equal. Still correct once the textarea grows multi-line
|
|
||||||
(a common composer-bar convention — Slack/Discord centre their
|
|
||||||
send affordance against a grown box too, not pin it to the first
|
|
||||||
line). */
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
/* The dashed in-frame separator is dropped — see the
|
/* The dashed in-frame separator is dropped — see the
|
||||||
.agent-composer .term-input override above for the floating-bar
|
.agent-composer .term-input override above for the floating-bar
|
||||||
|
|
@ -782,6 +747,9 @@ pre.diff {
|
||||||
border-top: 1px dashed var(--purple-dim);
|
border-top: 1px dashed var(--purple-dim);
|
||||||
padding-top: 0.5em;
|
padding-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
.term-input .prompt, .term-input .submit-hint {
|
||||||
|
padding-top: 0.25em;
|
||||||
|
}
|
||||||
.term-input .prompt {
|
.term-input .prompt {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 60%, transparent);
|
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 60%, transparent);
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
// <Header> — the agent page's identity strip: icon, glyphic title, the
|
|
||||||
// "swarm / hive" label row, and a slot for the status row
|
|
||||||
// (`<StatusChips>` — ../status-chips/, kept separate so this component
|
|
||||||
// has no opinion on what badges exist). Same three-column shape as the
|
|
||||||
// old `#agent-header` markup (icon · main · pills) — see the `pills`
|
|
||||||
// prop doc below for what moved into that third column and why.
|
|
||||||
// Reuses `agent.css`'s existing `.agent-header*`/`.agent-icon` rules
|
|
||||||
// (loaded globally by index.html) rather than a component-scoped
|
|
||||||
// stylesheet — no new visual language needed for the chrome itself.
|
|
||||||
|
|
||||||
// Measures its own rendered height via ResizeObserver, writes it to
|
|
||||||
// `--agent-header-real-h` (agent.css's downstream consumers —
|
|
||||||
// `.agent-status-overlay`, `.agent-main`'s scroll padding — prefer
|
|
||||||
// this over the static `--agent-header-h` guess). A static guess was
|
|
||||||
// the root cause of two "login card behind the header" reports on
|
|
||||||
// this PR (mara, live): any header content that can wrap an extra
|
|
||||||
// line breaks the guess at whatever width triggers it, and no width
|
|
||||||
// can be promised safe. Deliberately a SEPARATE var, not an overwrite
|
|
||||||
// of `--agent-header-h` in place: `.agent-header`'s own `min-height`
|
|
||||||
// also reads that var — overwriting it in place would make the
|
|
||||||
// header's own size react to its own just-measured height every
|
|
||||||
// tick, a real ResizeObserver feedback loop (confirmed live on the
|
|
||||||
// first pass of this fix). `--agent-header-h` stays the static floor;
|
|
||||||
// `--agent-header-real-h` is output-only.
|
|
||||||
import { useEffect, useRef } from 'preact/hooks';
|
|
||||||
import type { ComponentChildren } from 'preact';
|
|
||||||
|
|
||||||
// Icon 404s when the agent has no `hyperhive.icon` override (no
|
|
||||||
// bundled server-side default any more — see
|
|
||||||
// `hive_agent::web_ui::screen::serve_icon`). Ports app.js's
|
|
||||||
// `bindHeaderIconFallback` exactly: fire-and-forget load, swap to
|
|
||||||
// `/favicon.svg` on failure, `dataset.fallback` guards against a
|
|
||||||
// 404-on-the-fallback-itself loop.
|
|
||||||
function handleIconError(e: Event) {
|
|
||||||
const img = e.currentTarget as HTMLImageElement;
|
|
||||||
if (img.dataset.fallback) return;
|
|
||||||
img.dataset.fallback = '1';
|
|
||||||
img.src = '/favicon.svg';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeaderProps {
|
|
||||||
label: string;
|
|
||||||
hiveLabel?: string | null;
|
|
||||||
/** The status/badge row (`<StatusChips>`) — lives in the
|
|
||||||
* `.agent-header-pills` cluster now, alongside `pills` (see below),
|
|
||||||
* not stacked as its own row under the title. */
|
|
||||||
children?: ComponentChildren;
|
|
||||||
/** Flyout triggers (inbox/todos/links pills, the overflow menu
|
|
||||||
* button) — and, since this round, `children` (the status badges)
|
|
||||||
* too, both in `.agent-header-pills`, the SAME header-level column
|
|
||||||
* the old markup used for pills alone. First pass of this ask
|
|
||||||
* nested pills into a row inside `.agent-header-main` instead —
|
|
||||||
* backwards, mara corrected live: "i meant move badges to where the
|
|
||||||
* dropdowns are, not the other way around. this way the height does
|
|
||||||
* not change even on widescreen" — `.agent-header-main` now only
|
|
||||||
* ever holds the title + hive-label rows, which don't wrap in
|
|
||||||
* practice, so it stays a stable height; only this pills-cluster
|
|
||||||
* column (already used to absorbing a variable pill count) grows to
|
|
||||||
* fit badges+pills together. Meta-nav (`<MetaNav>`) lives in here
|
|
||||||
* too, as a fixed-size trigger — not the old markup's inline list in
|
|
||||||
* the title row (`<nav id="meta-links">`, docs/web-ui/agent.md). */
|
|
||||||
pills?: ComponentChildren;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
|
|
||||||
const ref = useRef<HTMLElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
||||||
// getBoundingClientRect() (not ResizeObserver's own contentRect) —
|
|
||||||
// we want the full visual box padding+border included, the thing
|
|
||||||
// content below the fixed header actually needs to clear, not the
|
|
||||||
// content-box-only measurement ResizeObserver's entry defaults to.
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`);
|
|
||||||
});
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header class="agent-header" ref={ref}>
|
|
||||||
<img class="agent-icon" src="icon" alt="" onError={handleIconError} />
|
|
||||||
<div class="agent-header-main">
|
|
||||||
<div class="agent-header-row agent-header-title-row">
|
|
||||||
<h2 class="agent-header-title">◆ {label} ◆</h2>
|
|
||||||
</div>
|
|
||||||
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
|
|
||||||
</div>
|
|
||||||
{children || pills ? (
|
|
||||||
<div class="agent-header-pills">
|
|
||||||
{children}
|
|
||||||
{pills}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
// <HeaderPill> — the inbox/todos flyout triggers in the header's right
|
|
||||||
// cluster. Reuses agent.css's existing `.hive-pill.header-pill…` rules
|
|
||||||
// (loaded globally) — same "layout is a like-for-like port, the
|
|
||||||
// component model is the change" approach as Header.tsx. Hidden
|
|
||||||
// (renders nothing) at count 0, matching the old page's `pill.hidden =
|
|
||||||
// count === 0`.
|
|
||||||
export interface HeaderPillProps {
|
|
||||||
kind: 'inbox' | 'todos';
|
|
||||||
icon: string;
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) {
|
|
||||||
if (count === 0) return null;
|
|
||||||
return (
|
|
||||||
<button type="button" class={`hive-pill header-pill header-pill-${kind}`} onClick={onClick} title={`open ${label} flyout`}>
|
|
||||||
<span class="header-pill-icon" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span class="header-pill-label">{label}</span>
|
|
||||||
<span class="header-pill-count">{count}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
// <InboxPanel> — content rendered inside <SidePanel> for the inbox
|
|
||||||
// flyout. "mark all read" POSTs to the *dashboard's* origin (not this
|
|
||||||
// agent's own backend) via a plain `fetch()`, same as app.js's
|
|
||||||
// `buildInboxMarkAllRow` — unlike pause/resume this one's fine as a
|
|
||||||
// fetch even when the two are genuinely cross-origin, because
|
|
||||||
// `hive-c0re`'s `/api/agent/{name}/mark-all-read` route replies with
|
|
||||||
// JSON, not a redirect; in the common gateway-fronted deployment
|
|
||||||
// `dashboardBase` already resolves to `location.origin` anyway (see
|
|
||||||
// dashboardBase.ts), so it's same-origin in practice.
|
|
||||||
import { useState } from 'preact/hooks';
|
|
||||||
import { useConfirmClick } from '../lib/useConfirmClick.js';
|
|
||||||
import type { InboxRow } from '../types.js';
|
|
||||||
|
|
||||||
export interface InboxPanelProps {
|
|
||||||
rows: InboxRow[];
|
|
||||||
label: string;
|
|
||||||
dashboardBase: string;
|
|
||||||
onCleared: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTs(unixSeconds: number): string {
|
|
||||||
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) {
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
async function markAllRead() {
|
|
||||||
if (!dashboardBase || !label) {
|
|
||||||
setStatus('dashboard url / label unknown');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
|
||||||
setStatus('clearing…');
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
const n = Number((data as { marked?: number }).marked) || 0;
|
|
||||||
setStatus(`✓ marked ${n} as read`);
|
|
||||||
onCleared();
|
|
||||||
} else {
|
|
||||||
setStatus(`failed: http ${resp.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setStatus(`failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { armed, trigger } = useConfirmClick(markAllRead);
|
|
||||||
|
|
||||||
if (!rows.length) {
|
|
||||||
return <p class="side-panel-empty">inbox empty.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="agent-inbox">
|
|
||||||
<div class="inbox-mark-all-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inbox-mark-all-btn"
|
|
||||||
onClick={trigger}
|
|
||||||
disabled={busy}
|
|
||||||
title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible."
|
|
||||||
>
|
|
||||||
{armed ? 'sure? click again' : '✓ mark all read'}
|
|
||||||
</button>
|
|
||||||
<span class="inbox-mark-status">{status}</span>
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{rows.map((m) => (
|
|
||||||
<li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}>
|
|
||||||
{m.in_reply_to != null && <span class="inbox-reply-tag">↳ reply · </span>}
|
|
||||||
<span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '}
|
|
||||||
<span class="inbox-sep">→</span> <span class="inbox-body">{m.body}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
// Live event pane — Preact owner of the DOM node `useLiveStream`'s rows
|
|
||||||
// get rendered into. Deliberately NOT the old MutationObserver + rAF
|
|
||||||
// snap-animation + `smoothScrollingUntil` gate machinery
|
|
||||||
// (@hive/shared/terminal.js) — mara explicitly flagged that the old
|
|
||||||
// page's autoscroll breaks when new events land while the operator has
|
|
||||||
// scrolled up, and not to copy it 1:1. This is the "do it properly the
|
|
||||||
// preact way" version:
|
|
||||||
//
|
|
||||||
// - `stickToBottom` is plain state, updated synchronously from the
|
|
||||||
// scroll handler (near-bottom → true, scrolled away → false). No
|
|
||||||
// separate "am I mid-animation" gate to fight with it — there's no
|
|
||||||
// animation to fight, snapping is an instant `scrollTop` write in a
|
|
||||||
// `useLayoutEffect` that runs once after Preact has already
|
|
||||||
// committed the new rows to the DOM, so it always sees the real
|
|
||||||
// `scrollHeight`, never a stale pre-mutation one.
|
|
||||||
// - A "load older" prepend is told apart from a normal append via
|
|
||||||
// `prependingRef` (set immediately before calling `loadMore()`) so
|
|
||||||
// the effect can choose the right response: viewport-preserving
|
|
||||||
// scrollTop compensation for a prepend, stick-to-bottom-or-count-
|
|
||||||
// unseen for an append. The old code inferred this from DOM
|
|
||||||
// mutation shape after the fact; here the caller already knows
|
|
||||||
// which one it did, so there's nothing to infer.
|
|
||||||
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
|
|
||||||
import { forwardRef } from 'preact/compat';
|
|
||||||
import { useLiveStream } from '../hooks/useLiveStream.js';
|
|
||||||
import { Row } from './Row.js';
|
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
|
||||||
const LOAD_MORE_SCROLL_PX = 80;
|
|
||||||
|
|
||||||
export interface LiveStreamProps {
|
|
||||||
/** Forwarded to useLiveStream — fires on live turn_start/turn_end so
|
|
||||||
* the caller can refresh `/api/state` sooner than its poll interval. */
|
|
||||||
onLiveTurnBoundary?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Imperative escape hatch for TermInput's local-only slash commands
|
|
||||||
* (`/help`, `/clear`) — same shape as app.js's old `termAPI` object,
|
|
||||||
* kept as a ref handle rather than lifting the whole row array up to
|
|
||||||
* Root so LiveStream/useLiveStream still own their state privately. */
|
|
||||||
export interface LiveStreamHandle {
|
|
||||||
pushNote: (text: string) => void;
|
|
||||||
clear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LiveStream = forwardRef<LiveStreamHandle, LiveStreamProps>(function LiveStream(
|
|
||||||
{ onLiveTurnBoundary },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary });
|
|
||||||
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
|
|
||||||
const logRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
|
||||||
const [unseen, setUnseen] = useState(0);
|
|
||||||
const prevRowCount = useRef(0);
|
|
||||||
const prependingRef = useRef(false);
|
|
||||||
const preScrollHeightRef = useRef(0);
|
|
||||||
|
|
||||||
function isNearBottom(el: HTMLDivElement): boolean {
|
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
const el = logRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const nearBottom = isNearBottom(el);
|
|
||||||
setStickToBottom(nearBottom);
|
|
||||||
if (nearBottom) setUnseen(0);
|
|
||||||
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLoadMore() {
|
|
||||||
const el = logRef.current;
|
|
||||||
if (!el || loadingMore || !hasMore) return;
|
|
||||||
prependingRef.current = true;
|
|
||||||
preScrollHeightRef.current = el.scrollHeight;
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function jumpToBottom() {
|
|
||||||
const el = logRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
||||||
setStickToBottom(true);
|
|
||||||
setUnseen(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs after every commit that changed `rows` — i.e. after the browser
|
|
||||||
// has already laid out the new/coalesced content, so `scrollHeight`
|
|
||||||
// below is always current.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = logRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const added = rows.length - prevRowCount.current;
|
|
||||||
prevRowCount.current = rows.length;
|
|
||||||
|
|
||||||
if (prependingRef.current) {
|
|
||||||
prependingRef.current = false;
|
|
||||||
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stickToBottom) {
|
|
||||||
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
||||||
} else if (added > 0) {
|
|
||||||
setUnseen((n) => n + added);
|
|
||||||
}
|
|
||||||
}, [rows, stickToBottom]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="terminal-wrap">
|
|
||||||
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
|
|
||||||
{hasMore && (
|
|
||||||
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}>
|
|
||||||
{loadingMore ? '↑ loading…' : '↑ load older'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{rows.map((row) => (
|
|
||||||
<Row key={row.key} row={row} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{unseen > 0 && (
|
|
||||||
<button type="button" className="tail-pill visible" onClick={jumpToBottom}>
|
|
||||||
↓ {unseen} new
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/* .login-card — one elevated-surface card for the whole login-recovery
|
|
||||||
overlay, replacing the old markup's per-`<p>`/`<form>` boxing
|
|
||||||
(`agent.css`'s `.agent-status-overlay > *`, still needed there
|
|
||||||
verbatim since app.js's still-live markup depends on it until the
|
|
||||||
index.html cutover — this file coexists with it rather than editing
|
|
||||||
it). Same visual language as `Dropdown`'s `.ui-dropdown`
|
|
||||||
(`@hive/shared/dropdown/Dropdown.css`) — background/border/radius/
|
|
||||||
shadow values match exactly, so this reads as the same "popup" family
|
|
||||||
as every other floating surface in the app, just laid out as a static
|
|
||||||
block instead of an anchored, position:absolute menu (that part of
|
|
||||||
`Dropdown` is specific to hanging under a trigger and doesn't apply
|
|
||||||
here — `.agent-status-overlay` already centres this card itself). */
|
|
||||||
.login-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6em;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
|
|
||||||
padding: 1em 1.2em;
|
|
||||||
}
|
|
||||||
.login-card > * {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
// <LoginFlow> — the `needs_login_idle` / `needs_login_in_progress`
|
|
||||||
// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress`
|
|
||||||
// as real functional UI, not just a status label: an agent whose
|
|
||||||
// credentials got rotated (or hit `/logout`) has NO other way back
|
|
||||||
// online, so this is load-bearing, not cosmetic. Reuses agent.css's
|
|
||||||
// `.status-needs-login`/`.btn-login`/`.loginform`/`.loginform-reveal`
|
|
||||||
// rules verbatim (same class names as the old markup).
|
|
||||||
//
|
|
||||||
// mara (screenshot review): "reuse the popup component for the login,
|
|
||||||
// the floating separate panels should be one card." The old markup's
|
|
||||||
// `.agent-status-overlay > *` rule boxed every direct-child `<p>`/
|
|
||||||
// `<form>` separately (a fine translation of `el().append()`-per-line,
|
|
||||||
// but not what a hand-authored component should do) — this renders one
|
|
||||||
// `.login-card` instead, styled with the same "elevated surface" look
|
|
||||||
// `Dropdown`'s `.ui-dropdown` already established (`@hive/shared/
|
|
||||||
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
|
|
||||||
// render is menu-item-shaped, not a generic content container — not a
|
|
||||||
// fit for a login form + output pane).
|
|
||||||
import { useState } from 'preact/hooks';
|
|
||||||
import type { SessionView } from '../types.js';
|
|
||||||
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js';
|
|
||||||
import './LoginFlow.css';
|
|
||||||
|
|
||||||
function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function submit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
postLoginStart().then((r) => {
|
|
||||||
setBusy(false);
|
|
||||||
if (r.ok) onStarted();
|
|
||||||
else setError(r.detail ?? 'failed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="login-card">
|
|
||||||
<p class="status-needs-login">◌ NEEDS L0G1N</p>
|
|
||||||
<p>
|
|
||||||
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is
|
|
||||||
paused until you log in.
|
|
||||||
</p>
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
|
||||||
◆ ST4RT L0G1N
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{error ? <p class="meta">error: {error}</p> : null}
|
|
||||||
<p class="meta">
|
|
||||||
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear
|
|
||||||
here when claude emits it; paste the resulting code back into the form below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) {
|
|
||||||
const [code, setCode] = useState('');
|
|
||||||
const [reveal, setReveal] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const finished = session?.finished ?? false;
|
|
||||||
|
|
||||||
function submitCode(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!code) return;
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
// Success: the next `/api/state` poll flips `status` back to
|
|
||||||
// `online` (or leaves it `needs_login_in_progress` on a bad code,
|
|
||||||
// with the process's own stderr showing up in `output` below) —
|
|
||||||
// nothing local to update here either way.
|
|
||||||
postLoginCode(code).then((r) => {
|
|
||||||
setBusy(false);
|
|
||||||
if (!r.ok) setError(r.detail ?? 'failed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
setBusy(true);
|
|
||||||
postLoginCancel().then(() => {
|
|
||||||
setBusy(false);
|
|
||||||
onDone();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="login-card">
|
|
||||||
<p class="status-needs-login">◌ L0G1N 1N PR0GRESS</p>
|
|
||||||
{session?.url ? (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
▶{' '}
|
|
||||||
<a href={session.url} target="_blank" rel="noreferrer">
|
|
||||||
{session.url}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p class="meta">
|
|
||||||
open this URL in a browser, complete the OAuth flow, paste the resulting code below.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p class="meta">waiting for claude to emit an OAuth URL on stdout… (output below)</p>
|
|
||||||
)}
|
|
||||||
{!finished ? (
|
|
||||||
<form class="loginform" onSubmit={submitCode}>
|
|
||||||
<input
|
|
||||||
name="code"
|
|
||||||
type={reveal ? 'text' : 'password'}
|
|
||||||
placeholder="paste OAuth code here (hidden)"
|
|
||||||
required
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
spellcheck={false}
|
|
||||||
value={code}
|
|
||||||
onInput={(e) => setCode((e.target as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="loginform-reveal"
|
|
||||||
title="show / hide pasted code"
|
|
||||||
aria-label="show / hide pasted OAuth code"
|
|
||||||
aria-pressed={reveal}
|
|
||||||
onClick={() => setReveal((r) => !r)}
|
|
||||||
>
|
|
||||||
👁
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
|
||||||
◆ S3ND C0DE
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
|
|
||||||
<button type="submit" class="btn btn-cancel" disabled={busy}>
|
|
||||||
cancel + kill
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{finished ? (
|
|
||||||
<p class="status-needs-login">
|
|
||||||
claude process exited: {session?.exit_note || 'exited'}. Start over if needed.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{error ? <p class="meta">error: {error}</p> : null}
|
|
||||||
<h3>output</h3>
|
|
||||||
<pre class="diff">{session?.output || ''}</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoginFlow({
|
|
||||||
status,
|
|
||||||
session,
|
|
||||||
onRefresh,
|
|
||||||
}: {
|
|
||||||
status: 'needs_login_idle' | 'needs_login_in_progress';
|
|
||||||
session: SessionView | null;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div class="agent-status-overlay">
|
|
||||||
{status === 'needs_login_idle' ? (
|
|
||||||
<LoginIdle onStarted={onRefresh} />
|
|
||||||
) : (
|
|
||||||
<LoginInProgress session={session} onDone={onRefresh} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/* <MetaNav> popover — matches `@hive/shared`'s `Dropdown` (`.ui-dropdown`)
|
|
||||||
values exactly (background/border/radius/shadow, item padding/hover),
|
|
||||||
same "reuse the values, not the component" call `.login-card`
|
|
||||||
(LoginFlow.css) already made, for the same reason: this popover's
|
|
||||||
items are real `<a>` tags, not `Dropdown`'s command-dispatch
|
|
||||||
`<button>`s. Right-anchored, same as OverflowMenu.css's
|
|
||||||
`.header-overflow-anchor` — MetaNav sits in the same right-hand
|
|
||||||
pills cluster, so a left anchor would push it off-screen the same
|
|
||||||
way. */
|
|
||||||
.meta-nav-anchor {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.meta-nav-popover {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.25em);
|
|
||||||
right: 0;
|
|
||||||
left: auto;
|
|
||||||
z-index: 20;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 10em;
|
|
||||||
padding: 0.25em;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
.meta-nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
min-height: 2.75em;
|
|
||||||
padding: 0.4em 0.7em;
|
|
||||||
border-radius: 0.35em;
|
|
||||||
color: var(--fg);
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.meta-nav-item:hover {
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
// <MetaNav> — the header's meta-nav trigger: a single fixed-size Badge
|
|
||||||
// ("🔗") in the pills cluster that opens a popover listing
|
|
||||||
// stats/screen/forge/config + any `hyperhive.dashboardLinks` extras,
|
|
||||||
// sourced from the backend's `agent_links()` (the single source of
|
|
||||||
// truth — same list also feeds the dashboard card's icon strip).
|
|
||||||
//
|
|
||||||
// v1 of this rendered every link inline in the header's title row —
|
|
||||||
// mara, live: "the login card looks like it is behind the header"
|
|
||||||
// (a variable-width link list can grow the title row past the fixed
|
|
||||||
// `--agent-header-h` the rest of the page is offset against — see
|
|
||||||
// Header.tsx's comment) and "the links should have the same popout as
|
|
||||||
// the links in the nav bar of swarm-ui." This is that: same shape as
|
|
||||||
// swarm-ui's own `LinksMenu` (Shell/LinksMenu.tsx) — one quiet icon
|
|
||||||
// trigger, a popover on click, real `<a>` items (not a command-dispatch
|
|
||||||
// button list) so ctrl/middle-click and "copy link address" keep
|
|
||||||
// working. Trigger itself reuses `@hive/shared`'s `Badge` (icon-only,
|
|
||||||
// same shape as the header's `⋯` overflow trigger — same touch target,
|
|
||||||
// same hover/expanded treatment, one visual language for every header
|
|
||||||
// trigger) rather than a bespoke button. The popover isn't built from
|
|
||||||
// `@hive/shared`'s `Dropdown` — its items are always `<button>`s
|
|
||||||
// (command dispatch), which would lose real link semantics — but
|
|
||||||
// matches its `.ui-dropdown` visual values exactly (see MetaNav.css),
|
|
||||||
// the same "reuse the values, not the component" call `LoginFlow`'s
|
|
||||||
// `.login-card` already made for the same reason.
|
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
||||||
import { Badge } from '@hive/shared/badge.js';
|
|
||||||
import type { AgentLink } from '../types.js';
|
|
||||||
import './MetaNav.css';
|
|
||||||
|
|
||||||
export interface MetaNavProps {
|
|
||||||
links: AgentLink[];
|
|
||||||
forgePublicUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetaNav({ links, forgePublicUrl }: MetaNavProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Same "outside click or Escape closes" contract as Dropdown/OverflowMenu.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
|
|
||||||
}
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener('pointerdown', onPointerDown, true);
|
|
||||||
document.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Same kind -> URL resolution as app.js's old refreshState meta-links
|
|
||||||
// loop: `forge` needs `forgePublicUrl` set or the link is dropped
|
|
||||||
// entirely (never guessed from `<host>:3000`); `external` is already
|
|
||||||
// absolute; `container` is a same-origin path.
|
|
||||||
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
|
|
||||||
if (visible.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="meta-nav-anchor" ref={rootRef}>
|
|
||||||
<Badge value="🔗" title="agent links" onClick={() => setOpen((o) => !o)} expanded={open} />
|
|
||||||
{open ? (
|
|
||||||
<div class="meta-nav-popover" role="menu" aria-label="agent links">
|
|
||||||
{visible.map((lnk) => {
|
|
||||||
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={lnk.url}
|
|
||||||
class="meta-nav-item"
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{lnk.icon ? (
|
|
||||||
<span aria-hidden="true">{lnk.icon}</span>
|
|
||||||
) : null}
|
|
||||||
{lnk.label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
/* Anchors <Dropdown> under the "⋯" trigger — same positioning
|
|
||||||
contract as StatusChips.css's `.status-chip-anchor`, kept as its own
|
|
||||||
rule here rather than reused across components (see this file's
|
|
||||||
sibling .tsx comment) so OverflowMenu doesn't depend on StatusChips'
|
|
||||||
incidental CSS. */
|
|
||||||
.header-overflow-anchor {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
/* @hive/shared's Dropdown.css anchors left-edge-to-left-edge, correct
|
|
||||||
for a picker with room to its right (StatusChips' model/effort). The
|
|
||||||
overflow trigger is the header's right-most element instead, so a
|
|
||||||
left anchor pushes the menu off-screen — flip to right-edge-to-
|
|
||||||
right-edge here, scoped to this one consumer rather than changing
|
|
||||||
the shared default (which is still correct for every other caller). */
|
|
||||||
.header-overflow-anchor .ui-dropdown {
|
|
||||||
left: auto;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
// <OverflowMenu> — the header's `⋯` trigger. Deliberately trimmed vs.
|
|
||||||
// app.js's `populateOverflowMenu`: that one held dashboard-link/rebuild/
|
|
||||||
// new-session/logout/model-picker/effort-picker all in one flat list —
|
|
||||||
// the design guide's own named anti-example for a junk drawer. The
|
|
||||||
// model/effort pickers already moved to StatusChips' own Badge+Dropdown
|
|
||||||
// controls (this rewrite's first commit); new-session/logout now live
|
|
||||||
// as TermInput slash commands (typed, not menu-clicked, and already
|
|
||||||
// have their own two-step confirm). What's left here — rebuild + a
|
|
||||||
// dashboard back-link — are genuinely menu-shaped (rare, not tied to
|
|
||||||
// any other visible control), so they're still a `⋯` menu, just a much
|
|
||||||
// shorter one.
|
|
||||||
//
|
|
||||||
// Reuses `@hive/shared`'s `Badge`+`Dropdown` — same anchored-popover
|
|
||||||
// pair StatusChips' model/effort pickers use, not a bespoke popover.
|
|
||||||
// Rebuild's confirm is the same "select once to arm, select again to
|
|
||||||
// fire" pattern as TermInput's `/new-session`/`/logout` — adapted here
|
|
||||||
// to a menu click instead of a typed repeat, but the same "requires
|
|
||||||
// deliberate repetition, not a modal" idea.
|
|
||||||
import { useState } from 'preact/hooks';
|
|
||||||
import { Badge } from '@hive/shared/badge.js';
|
|
||||||
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
|
|
||||||
import { submitRebuild } from '../lib/rebuildAction.js';
|
|
||||||
import './OverflowMenu.css';
|
|
||||||
|
|
||||||
export interface OverflowMenuProps {
|
|
||||||
label: string;
|
|
||||||
dashboardBase: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverflowMenu({ label, dashboardBase }: OverflowMenuProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [armed, setArmed] = useState(false);
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
setOpen(false);
|
|
||||||
setArmed(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: DropdownOption[] = [
|
|
||||||
{ value: 'dashboard', label: '↑ dashboard' },
|
|
||||||
{ value: 'rebuild', label: armed ? '↻ rebuild — click again to confirm' : '↻ rebuild container' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function onSelect(value: string) {
|
|
||||||
if (value === 'dashboard') {
|
|
||||||
window.open(`${dashboardBase}dashboard.html`, '_blank', 'noopener');
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value === 'rebuild') {
|
|
||||||
if (armed) {
|
|
||||||
submitRebuild(dashboardBase, label);
|
|
||||||
close();
|
|
||||||
} else {
|
|
||||||
setArmed(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="header-overflow-anchor">
|
|
||||||
<Badge value="⋯" title="more actions" onClick={() => setOpen((o) => !o)} expanded={open} />
|
|
||||||
<Dropdown open={open} options={options} label="more actions" onSelect={onSelect} onClose={close} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
// Renders one `StreamRow` — flat `<div class="row …">` or expandable
|
|
||||||
// `<details class="row …">`, matching @hive/shared/terminal.css's
|
|
||||||
// existing row-kind classes exactly (see docs/terminal-rendering.md).
|
|
||||||
// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the
|
|
||||||
// taxonomy's visual language isn't what mara asked to change, the
|
|
||||||
// component *model* underneath it is.
|
|
||||||
import { useEffect, useRef } from 'preact/hooks';
|
|
||||||
import type { StreamRow } from '../lib/streamRow.js';
|
|
||||||
import { linkifyToNodes } from '../lib/linkify.js';
|
|
||||||
import { renderMarkdown } from '../lib/markdown.js';
|
|
||||||
|
|
||||||
function MarkdownBody({ text }: { text: string }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const html = renderMarkdown(text);
|
|
||||||
useEffect(() => {
|
|
||||||
// marked autolinks URLs but leaves them same-tab — open externally
|
|
||||||
// so a click never navigates the terminal away.
|
|
||||||
ref.current?.querySelectorAll('a[href]').forEach((a) => {
|
|
||||||
a.setAttribute('target', '_blank');
|
|
||||||
a.setAttribute('rel', 'noopener noreferrer');
|
|
||||||
});
|
|
||||||
}, [html]);
|
|
||||||
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
|
|
||||||
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiffBody({ text }: { text: string }) {
|
|
||||||
const lines = String(text).split('\n');
|
|
||||||
return (
|
|
||||||
<pre className="tool-body diff-body">
|
|
||||||
{lines.map((line, i) => {
|
|
||||||
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
|
|
||||||
return (
|
|
||||||
<span key={i} className={cls}>
|
|
||||||
{line}
|
|
||||||
{'\n'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Row({ row }: { row: StreamRow }) {
|
|
||||||
if (row.details) {
|
|
||||||
return (
|
|
||||||
<details className={`row ${row.cssClass}`} open={row.defaultOpen || undefined}>
|
|
||||||
<summary>
|
|
||||||
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
|
|
||||||
<span className="summary-text">{row.text}</span>
|
|
||||||
</summary>
|
|
||||||
{row.diffBody != null && <DiffBody text={row.diffBody} />}
|
|
||||||
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
|
|
||||||
{row.plainBody != null && <pre className="tool-body">{linkifyToNodes(row.plainBody)}</pre>}
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={`row ${row.cssClass}`}>
|
|
||||||
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
|
|
||||||
{row.text != null && linkifyToNodes(row.text)}
|
|
||||||
{row.meta?.map((m) => (
|
|
||||||
<span key={m.cls} className={m.cls}>
|
|
||||||
{m.text}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{row.childText != null && <div className={row.childText.cls}>{row.childText.text}</div>}
|
|
||||||
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
/* Drawer chrome for <SidePanel> — backdrop, drawer box, header, close
|
|
||||||
button. Content styling (.agent-inbox, .inbox-*, .side-panel-empty)
|
|
||||||
already ships globally via agent.css; the two rules at the bottom
|
|
||||||
here re-target agent.css's old `hive-side-panel .agent-inbox …`
|
|
||||||
flatten-when-inside-a-panel overrides at this component's own class
|
|
||||||
instead, since this is a plain Preact tree, not a custom element. */
|
|
||||||
.agent-side-panel-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 60;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 160ms ease;
|
|
||||||
}
|
|
||||||
.agent-side-panel-backdrop.open {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.agent-side-panel-drawer {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: min(26em, 96vw);
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 61;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transform: translateX(100%);
|
|
||||||
transition: transform 200ms ease;
|
|
||||||
}
|
|
||||||
.agent-side-panel-drawer.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
.agent-side-panel-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.6em 0.8em;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.agent-side-panel-title {
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
.agent-side-panel-close {
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 1.1em;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.3em 0.5em;
|
|
||||||
min-width: 2.75em;
|
|
||||||
min-height: 2.75em;
|
|
||||||
}
|
|
||||||
.agent-side-panel-close:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.agent-side-panel-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.8em;
|
|
||||||
}
|
|
||||||
.agent-side-panel-body .agent-inbox {
|
|
||||||
margin: 0;
|
|
||||||
font-size: inherit;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.agent-side-panel-body .agent-inbox ul {
|
|
||||||
background: transparent;
|
|
||||||
border-left: 0;
|
|
||||||
padding: 0;
|
|
||||||
max-height: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
// <SidePanel> — the slide-in drawer for the inbox/todos flyouts.
|
|
||||||
// Deliberately a new Preact component, not the shared
|
|
||||||
// `<hive-side-panel>` shadow-DOM custom element (@hive/shared/
|
|
||||||
// side-panel.js) — same rationale as StatusChips' Badge/Dropdown:
|
|
||||||
// mara's ask was real Preact components for this rewrite, not a port
|
|
||||||
// of the old widget family. Drops that element's drag-to-resize +
|
|
||||||
// localStorage width persistence for this first slice (fixed width via
|
|
||||||
// CSS, see SidePanel.css) — a real simplification, not an oversight;
|
|
||||||
// revisit if a fixed width proves cramped in practice.
|
|
||||||
//
|
|
||||||
// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`)
|
|
||||||
// already ships globally via `agent.css` and is reused verbatim by
|
|
||||||
// InboxPanel/TodosPanel — only the drawer chrome itself is new here.
|
|
||||||
import { useEffect } from 'preact/hooks';
|
|
||||||
import type { ComponentChildren } from 'preact';
|
|
||||||
import './SidePanel.css';
|
|
||||||
|
|
||||||
export interface SidePanelProps {
|
|
||||||
open: boolean;
|
|
||||||
title: string;
|
|
||||||
onClose: () => void;
|
|
||||||
children?: ComponentChildren;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" />
|
|
||||||
<aside
|
|
||||||
class={`agent-side-panel-drawer${open ? ' open' : ''}`}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-hidden={!open}
|
|
||||||
aria-label={title}
|
|
||||||
>
|
|
||||||
<div class="agent-side-panel-head">
|
|
||||||
<span class="agent-side-panel-title">{title}</span>
|
|
||||||
<button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="agent-side-panel-body">{children}</div>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/* <StatusChips> layout. Colours all come from Badge/Dropdown's own CSS
|
|
||||||
(@hive/shared) — nothing here overrides a base16 slot. */
|
|
||||||
.status-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
}
|
|
||||||
.status-chip-anchor {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
/* mara, live, on a screenshot of this row: "badges and interactive
|
|
||||||
elements on same row have inconsistent size." Real cause: Badge.css's
|
|
||||||
`.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor
|
|
||||||
(model/effort pickers, pause) while a plain display `<span>` badge
|
|
||||||
(alive, state, ctx, cost — no `onClick`) stays compact, so the row
|
|
||||||
reads as two different pill sizes even though every badge here is
|
|
||||||
the same `<Badge>` component. Scoped to this row rather than
|
|
||||||
Badge.css itself — display-only badges elsewhere (e.g. the dashboard)
|
|
||||||
have no reason to grow, this is specifically about badges and
|
|
||||||
buttons sitting side by side in one row needing to read as one
|
|
||||||
family. */
|
|
||||||
.status-chips .ui-badge {
|
|
||||||
min-height: 2.75em;
|
|
||||||
padding-inline: 0.8em;
|
|
||||||
}
|
|
||||||
.status-chips-last-turn {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue