mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
d6b98f0c0d
* docs(adr): Guided Review ADR/spec/research + agent-provider fit studies
ADR 006 (Guided Review as a first-class feature), four implementation
spikes, synthesis, spec (iterated through preflight and review fixes),
recap, and the Flue/Pi provider-fit research syntheses.
* feat(review): Guided Review takeover + Pi agent-job provider
Guided Review (ADR 006) — a Linear-Guides-style chaptered review:
- guide agent-job provider (packages/server/guide/guide-review.ts):
schema-constrained sections (title/overview/file refs) over the live
patch, coverage-validated server-side (every changed file exactly once,
fabricated paths dropped, fail-closed on empty output), importance-first
ordering prompt with speed discipline (diff-first, no repo exploration)
- runs on claude/codex natively and cursor/opencode/pi via the marker
contract; guide-scoped low effort defaults for quicker generation
- takeover UI (packages/review-editor/components/guide/): Guide header
badge + Mod+Shift+G, full-width screen that CSS-hides (never unmounts)
the file tree/dock, empty state with inline model pickers, skeleton
loading, generating state with collapsed activity log, two-column
section cards (sticky prose column with internally-scrolling file list,
content-height diffs, review-independent collapse, peek semantics)
- annotation parity: guide diffs mount the real DiffViewer with
file-scoped handlers into the same CodeAnnotation state and feedback
export; per-section reviewed state persisted via /api/guide/:jobId
- reviewed/tour prompt speed sections; renderMarkdownProse shared between
tour and guide (fenced-block support, muted tone variant)
Pi agent-job provider:
- third MarkerEngine (pi --mode json --no-session --no-approve) with
live model-catalog discovery, thinking-level control (--thinking),
fail-closed marker parsing (Pi exits 0 on in-run errors by design)
- available for review + guide jobs from both launch surfaces
Shared UX:
- searchable, provider-grouped model pickers (auto over 12 options)
- full Claude version catalog with latest-resolving aliases labeled
- AgentControls primitives extracted from AgentsTab; cross-instance
settings sync in useAgentSettings
- Pi extension server hand-mirrors + vendor.sh entries throughout
* fix(guide): PR-993 review fixes + failure recovery ladder
Review fixes:
- coerce marker-engine title/intent at ingest (prompt-enforced output
could store non-strings and crash the takeover)
- keep placed files out of unplacedFiles (exactly-once guarantee)
- shared REVIEW_ENGINE_LABEL (fixes 'generated by pi' header; typed
exhaustiveness prevents recurrence)
- key ActiveGuide by jobId so per-guide focus state resets
- formatModel handles marker-engine tour/guide jobs (empty model chip)
- drop dead guideCodexFast; memoize estimateDiffHeight
- store + display Pi thinking level on job cards
Failure recovery ladder (auto -> one click -> manual -> regenerate):
- mechanical JSON repair pass (fences/slice/trailing-commas/bracket
balance) before any guide parse fail-closes
- failed payloads captured per job (200KB cap, per-engine extraction)
- 'Fix output' repair job: pure text transform on a schema-capable
engine (claude/codex preferred), forced low effort, lands as a
normal guide job; repairOf threaded through launch validation
- editable output panel: GET /api/guide/:jobId/output prefills a
textarea, POST /api/guide/:jobId/submit runs the same server-side
validation and opens the fixed guide; inline errors for iteration
- Pi extension mirrors + AGENTS.md endpoint/body docs
* fix(guide): string-aware mechanical JSON repair (self-review)
- terminate a dangling string literal before appending bracket closers
(truncation mid-string is the most common shape; closers appended
inside the string never parsed)
- strip trailing commas outside string literals only (the regex could
rewrite overview content, violating the repair contract)
* fix(guide): launch-snapshot validation, codex strict schema, repair persistence
PR-993 round-2 review fixes + live-test findings:
- validate guide completion (and manual repair) against the LAUNCH-time
changed-file set, snapshotted per job — switching diff/base/PR while a
guide generates no longer destroys valid output (client already
degrades stale refs per-file)
- codex strict structured output: unplacedFiles now required in the
guide schema (OpenAI 400s schemas with optional properties under
additionalProperties:false — live-confirmed, then live-verified fixed:
5 sections / 83 files placed on a ChatGPT-account codex)
- manual repair survives reload: successful /submit flips the job to
done via completeJobExternally + status guard on the endpoint
- codex model defaults -> gpt-5.5 (5.3-codex is deprecated; ChatGPT
accounts reject it) with one-shot migration of saved picks; guide
codex default reasoning stays low
- pi extension guide branch snapshots launch state (TOCTOU hygiene)
- paragraph collector no longer swallows an unspaced code fence
- tour hook backports: out-of-order fetch guard; save outside updater
- guide empty-state copy rewritten value-first
* fix(guide): PR-993 round-3 — repair-diff fidelity, failure surfacing, pi tool hardening
- repair jobs validate against the FAILED job's recorded file set (the
payload under repair references that changeset; validating against the
diff on screen at repair time re-introduced destroy-on-switch)
- takeover follows the NEWEST running guide (progress + Cancel), and a
failure newer than the shown guide surfaces as a dismissible strip
(error + Fix output + Details) instead of being silently masked
- pi jobs run with --exclude-tools edit,write (bash stays: the agent
needs git inspection; full read-only would break generation)
- tour/guide result ingestion fails closed on unexpected throw (done-
looking jobs no longer 404 their result)
- sidebar guide mode gated on file availability like the header badge
- drop orphaned DEFAULT_GUIDE_CODEX_FAST (fast mode intentionally not
offered for guide); retry owns its fetch cancellation; final
trailing-comma pass after bracket-closing in the repair ladder;
collapsed-row checkbox/expand are sibling buttons (a11y)
* fix(guide): PR-993 round-4 — callout text, guide job detail, toggle races
- single-line callouts (> [!IMPORTANT] message) keep their message — the
form our own prompt solicits rendered an empty labeled box; type now
derives from the [!TAG] capture, not a whole-line scan that mistyped
'> [!NOTE] this is important'
- job detail panel gets a guide status card (Open guide via a new
ReviewStateContext.openGuide) instead of mislabeling 'Guide Generated'
as a red Incorrect review
- marker-engine guide jobs get readable live logs (engine fallback in
the formatter lookup; provider stays 'guide')
- repair prefers the failed job's own schema-capable engine (proven
working on this machine) before falling back by binary presence
- toggleReviewed/toggleChecked: pure functional updaters + persistence
effect with seed-skip — race-proof AND StrictMode-safe (resolves the
tradeoff previous rounds accepted)
- dedups: formatDuration/ElapsedTime shared from AgentsTab; whichCmd
exported in the pi extension; SEARCHABLE_THRESHOLD imported by
InlinePicker
- new renderMarkdownProse tests (single-line/multi-line callouts,
paragraph passthrough)
* fix(guide): PR-993 round-5 — keep engine-default model option, label Pi jobs
- GuideEmptyState's marker catalogs mirror AgentsTab's per-engine
semantics: opencode/pi prepend the engine-managed Default ('' value)
to the discovered list (dropping it left saved-default users a blank
pill with no way back); cursor replaces (its list includes 'auto')
- job detail provider pill labels pi review jobs 'Pi' instead of the
'Shell' fallback
* fix(guide): PR-993 round-6 — context scoping, stale models, repair engine, staging gate
- guide takeover + auto-open scoped to the current review context: a
guide launched against PR A no longer shows over PR B, and a guide
finishing for an away context defers its auto-open until the reviewer
returns to that context (unmarked in the dedupe set on purpose)
- guide launcher reconciles saved cursor/opencode/pi model ids against
the live catalog at read time (picker + launch share one effective
value) — no more posting dead ids after an account switch
- repair prefers the failed job's OWN engine whenever its binary is
present (provably runnable; marker binaries resolved via
MARKER_ENGINES — cursor's CLI is 'agent'); claude/codex are fallback
only, killing the broken-claude repair doom loop
- per-file staging gate (canStagePath) in guide diffs AND the
pre-existing same gap in ReviewDiffPanel — committed-only files in
since-base reviews no longer offer a no-op Git Add
* fix(review): scope tour auto-open to current context (self-review)
Same cross-context gap just fixed for guides: a tour finishing for PR A
popped its dialog over PR B. Both auto-open effects now share one
jobMatchesCurrentContext helper with the same deferred-open semantics.
* fix(guide): PR-993 round-7 — complete the context-scoping story
Round 6 scoped guides to their review context; this closes the two
surfaces that scoping left dangling, plus a focus gap:
- switching to a context that already HAS a completed guide now shows
that guide: the takeover falls back to the context's newest done
guide job when activeGuideJobId belongs elsewhere (previously landed
on the empty state with the guide sitting unreachable)
- 'Open guide' affordances are context-gated everywhere they exist:
job cards hide it for cross-context guides (opening can't switch
PRs), and the job detail panel explains where the guide belongs
instead of offering a dead button; one shared
jobMatchesReviewContext predicate now backs App, GuideScreen,
AgentsTab, and the detail panel
- file-chip navigation retargets the guide's focus arbiter (scrolling
under a stationary pointer fires no pointerenter, so the annotation
toolbar stayed bound to the previous diff)
* fix(tour): retry owns its fetch cancellation; document prUrl-only scoping
- useTourData retry converts to the nonce-driven effect re-run pattern
(the round-4 backport carried the fetch guard but not the retry fix
useGuideData got in the same batch — half a backport)
- jobMatchesReviewContext now documents WHY it matches by prUrl only
and not diffScope/diffContext: guides/tours reference files and
degrade per-file when the diff shifts; scope-strict matching would
hide useful artifacts on layer/base/mode switches. Deliberate,
do-not-tighten-without-UX-decision
* fix(guide): PR-993 round-9 — worktree-aware context, guide-scoped marker settings
- jobMatchesReviewContext now compares diffContext.worktreePath for
local jobs: prUrl + worktree define WHERE the review is (matched
strictly); mode/base/scope remain WHAT VIEW (deliberately loose) —
a guide launched against worktree A no longer opens over worktree B.
Jobs predating the snapshot coalesce to the main tree.
- Cursor/OpenCode/Pi get guide-scoped model/thinking settings
(guideCursor/guideOpencode/guidePi), mirroring the existing
guideClaude/guideCodex isolation: tuning a guide's marker model no
longer silently changes the next code review with that engine.
Defaults are each engine's natural default, not seeded from review
settings (same precedent as guideClaudeEffort='low'). AgentsTab's
reconcile effects snap both surfaces against the shared catalogs.
* fix(review): keep worktree parsing browser-safe (round-9 build fix)
parseWorktreeDiffType lives in shared/review-core, which imports
node:path at module top — pulling it into App.tsx broke the vite
browser build (typecheck and tests don't catch it; only the bundle
does). Context matching now reuses App's existing hand-parsed
activeWorktreePath memo — one parse, and it's the same one that
drives the sections/tree UI, so matching aligns with what's on
screen. Also fixes a deps-array reference to the removed memo that
bundled fine (treated as a global) but would have thrown a
ReferenceError at runtime.
* docs(review): mark worktree-subtype lockstep between server parser and App copy
* fix(review): PR-993 round-10 — guide shortcut gate, settings sync race, heading markdown
- bare a/v staging/viewed shortcuts suspend while the guide takeover is
open (the dock is only CSS-hidden, so a diff panel stayed 'active'
underneath and bare keys acted on an invisible file)
- cross-instance settings sync replaces the was-last-update-remote
boolean with value comparison (lastSyncedJsonRef): the flag conflated
'a commit happened' with 'the last change was remote', so a local
edit batched with an incoming broadcast silently skipped its own
cookie write and rebroadcast
- ### headings in guide/tour prose run through renderInlineMarkdown
like every other block (backticked symbols rendered as raw tokens);
regression test added
* fix(guide): PR-993 round-11 — reveal channel for sidebar jumps, repair exit code
- new guideRevealFile channel closes the documented round-7 gap and its
AI sibling: sidebar jumps (annotation clicks, AI line citations) made
while the guide takeover is open no longer mutate the hidden dock's
active file; instead the GuideSectionCard containing the target file
expands its collapsed (reviewed) section, focuses the diff, and
scrolls to it — so jumps into collapsed sections stop silently
no-opping. Cleared on guide close/switch so keyed remounts don't
replay the last reveal.
- completeJobExternally resets exitCode to 0 (both servers): a
successfully repaired guide kept showing the failed run's Exit 1 chip
in the job detail panel
* fix(guide): clear reveal channel in-batch at guide-switch sites (self-review)
The clear-on-change effect fires after a switched guide's keyed cards
have already mounted (child effects run before parent effects), leaving
one commit where a stale reveal from guide A could expand+scroll a
same-named file's section in guide B. All three setActiveGuideJobId
sites now clear synchronously in the same batch; the effect stays as a
backstop for close and future set sites.
* fix(guide): PR-993 round-12 — titled sections, honest large-PR validation
- sanitizeGuideSection gives every surviving section a non-empty title
('Untitled section' fallback): a diffs-only section rendered as a
blank chapter inside a 'Guide Generated' job — no parse failure, so
no recovery flow. Keeping (titled) beats dropping: placed files are
not in unplacedFiles, so dropping would orphan them silently.
- guide launches on large PRs (layerPatchIncomplete) recompute
changedFiles from the local checkout (git diff --numstat
origin/<base>...HEAD, rename/binary handling) when available — the
PR-mode prompt tells the agent to read that full local diff, but the
changed-files block and validation snapshot came from the truncated
platform patch, under-listing files to the model and then dropping
its valid refs (or failing the guide) at validation. Falls back to
the partial list on any failure. Mirrored in the Pi extension server.
Round-12's P1 (Pi marker extraction reads message_end, 'should' read
text_delta) was refuted by live probe: Pi's assistant message_end
carries the fully-assembled content blocks, which is exactly what
piExtractText reads; the delta-reading code the finding cross-
referenced is the Ask-AI STREAMING provider, a different job.
* test(guide): first direct coverage for guide-review's pure logic (self-review)
The module's repair ladder, validation, and stream parsing had zero
unit tests — exercised only end-to-end through live agent runs. Pins
the behaviors the review rounds fixed: blank-title fallback (round 12),
first-placement-wins dedup, changedFiles filtering + fail-closed empty
guides, prose-only vs lost-diffs section handling, unplacedFiles
merge/dedup/fabrication-filtering, title/intent coercion, trailing-
comma and unbalanced-bracket repair, truncated stream-line recovery.
13 tests; server suite 374 → 387.
* fix(guide): PR-993 round-13 — full-stack recompute regression, flag snapshot, unplaced reveal
- the round-12 large-PR recompute now runs ONLY in layer scope: in
full-stack scope launchPatch is already a local full recompute, and
the layer diff (origin/<base>...HEAD) was the WRONG file set — it
omits earlier stack layers' files, dropping their refs at validation
or failing the guide. Introduced by round 12; caught before any
release. Both servers.
- layerPatchIncomplete is snapshotted with the other launch locals
(launchLayerPatchIncomplete): coherence with launchPatch was
positional (same sync segment) — now structural, so a future await
inserted upstream can't silently desync them. Both servers.
- the 'Everything else' bucket handles guideRevealFile: sidebar jumps
to unplaced files now focus + scroll their diff (the section-card
effect only covers placed files) — closes the residual gap noted in
the round-11 self-review
217 lines
9.0 KiB
TypeScript
217 lines
9.0 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { ChevronDown, Plus } from 'lucide-react';
|
||
import { cn } from '../lib/utils';
|
||
|
||
// --- Launch-control primitives (ported from the prototype's sidebar) ---
|
||
|
||
// A labelled config row. Inline (label left, control right) by default; pass
|
||
// `stacked` to put a full-width control under the label — used for the model
|
||
// dropdown and the effort/reasoning segmented pickers, which need the room.
|
||
export function ConfigRow({ label, stacked, children }: { label: string; stacked?: boolean; children: React.ReactNode }) {
|
||
if (stacked) {
|
||
return (
|
||
<div className="space-y-1">
|
||
<span className="block text-[10px] text-muted-foreground/50">{label}</span>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="shrink-0 text-[10px] text-muted-foreground/50">{label}</span>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Pill selector for small option sets (effort, reasoning, tour engine).
|
||
export function SegmentedPicker({ options, value, onChange }: { options: Array<{ value: string; label: string }>; value: string; onChange: (v: string) => void }) {
|
||
return (
|
||
<div className="flex items-center gap-px rounded-lg bg-surface-1/50 p-0.5">
|
||
{options.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => onChange(opt.value)}
|
||
className={cn(
|
||
'flex-1 rounded-md px-2 py-1 font-medium text-[9px] transition-colors',
|
||
value === opt.value
|
||
? 'bg-card text-foreground shadow-sm'
|
||
: 'text-muted-foreground/50 hover:text-muted-foreground',
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Animated on/off switch (fast mode).
|
||
export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(!checked)}
|
||
className={cn('relative h-5 w-9 shrink-0 rounded-full transition-colors', checked ? 'bg-primary' : 'bg-border/50')}
|
||
>
|
||
<span
|
||
className={cn(
|
||
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform',
|
||
checked && 'translate-x-4',
|
||
)}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// Lists longer than this get a type-to-filter input and provider grouping —
|
||
// marker-engine catalogs (Cursor ~150, Pi ~59, OpenCode ~25) are unusable as a
|
||
// flat list; the classic Claude/Codex catalogs (7–11) stay a plain menu.
|
||
// Exported so other pickers over the same catalogs (e.g. GuideEmptyState's
|
||
// InlinePicker in review-editor) share this one threshold instead of hardcoding it.
|
||
export const SEARCHABLE_THRESHOLD = 12;
|
||
|
||
export interface SelectOptionGroup {
|
||
/** null = ungrouped (single flat list). */
|
||
label: string | null;
|
||
options: Array<{ value: string; label: string }>;
|
||
}
|
||
|
||
/** Group `provider/model`-shaped option lists by their provider prefix.
|
||
* Returns one ungrouped bucket when the list is small or ids aren't
|
||
* provider-prefixed (Claude aliases, Codex models, Cursor ids). */
|
||
export function groupModelOptions(options: Array<{ value: string; label: string }>): SelectOptionGroup[] {
|
||
const slashed = options.filter((o) => o.value.includes('/')).length;
|
||
if (options.length <= SEARCHABLE_THRESHOLD || slashed < options.length / 2) {
|
||
return [{ label: null, options }];
|
||
}
|
||
const groups = new Map<string, Array<{ value: string; label: string }>>();
|
||
for (const o of options) {
|
||
const key = o.value.includes('/') ? o.value.slice(0, o.value.indexOf('/')) : 'other';
|
||
const bucket = groups.get(key);
|
||
if (bucket) bucket.push(o);
|
||
else groups.set(key, [o]);
|
||
}
|
||
return [...groups.entries()].map(([label, opts]) => ({ label, options: opts }));
|
||
}
|
||
|
||
/** Strip a redundant `group/` prefix from an option label rendered under its
|
||
* group header (e.g. "anthropic/claude-sonnet-4-6" under "anthropic"). */
|
||
export function labelWithinGroup(label: string, group: string | null): string {
|
||
if (group && label.startsWith(`${group}/`)) return label.slice(group.length + 1);
|
||
return label;
|
||
}
|
||
|
||
// Dropdown button + downward popover. Reused for the provider selector and the
|
||
// model picker (whose 7–9 options rule out a segmented control). The popover
|
||
// opens downward (`top-full`) because the launch panel is pinned to the top of
|
||
// the tab. Long catalogs automatically gain a filter input + provider grouping.
|
||
export function SelectMenu({ value, options, onChange, icon, placeholder, footerAction }: { value: string; options: Array<{ value: string; label: string }>; onChange: (v: string) => void; icon?: React.ReactNode; placeholder?: string; footerAction?: { label: string; onClick: () => void } }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState('');
|
||
const current = options.find((o) => o.value === value);
|
||
const searchable = options.length > SEARCHABLE_THRESHOLD;
|
||
|
||
const q = query.trim().toLowerCase();
|
||
const filtered = q
|
||
? options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
||
: options;
|
||
const grouped = groupModelOptions(filtered);
|
||
|
||
const close = () => {
|
||
setOpen(false);
|
||
setQuery('');
|
||
};
|
||
const select = (v: string) => {
|
||
onChange(v);
|
||
close();
|
||
};
|
||
|
||
return (
|
||
<div className="relative w-full">
|
||
<button
|
||
type="button"
|
||
onClick={() => (open ? close() : setOpen(true))}
|
||
className="flex w-full items-center gap-2 rounded-lg border border-border/30 bg-surface-1/30 px-2.5 py-1.5 text-left transition-colors hover:bg-surface-1/50"
|
||
>
|
||
{icon}
|
||
<span className="min-w-0 flex-1 truncate text-[11px] text-foreground/80">{current?.label ?? placeholder}</span>
|
||
<ChevronDown className={cn('shrink-0 text-muted-foreground/30 transition-transform', open && 'rotate-180')} size={10} />
|
||
</button>
|
||
{open && (
|
||
<>
|
||
<div className="fixed inset-0 z-10" onClick={close} />
|
||
<div
|
||
className={cn(
|
||
'absolute right-0 top-full left-0 z-20 mt-1 rounded-xl bg-card shadow-[var(--card-shadow)] ring-1 ring-border/20',
|
||
searchable && 'min-w-[240px]',
|
||
)}
|
||
>
|
||
{searchable && (
|
||
<div className="p-1 pb-0">
|
||
<input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Escape') close();
|
||
if (e.key === 'Enter' && filtered.length > 0) select(filtered[0].value);
|
||
}}
|
||
placeholder="Type to filter…"
|
||
className="w-full rounded-lg border border-border/30 bg-surface-1/30 px-2.5 py-1.5 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-border/60"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className={cn('overflow-y-auto p-1', searchable ? 'max-h-72' : 'max-h-56')}>
|
||
{filtered.length === 0 && (
|
||
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/50">No matches</div>
|
||
)}
|
||
{grouped.map((group) => (
|
||
<React.Fragment key={group.label ?? '__flat'}>
|
||
{group.label && (
|
||
<div className="px-2.5 pb-0.5 pt-1.5 font-medium text-[9px] uppercase tracking-wider text-muted-foreground/40">
|
||
{group.label}
|
||
</div>
|
||
)}
|
||
{group.options.map((o) => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => select(o.value)}
|
||
className={cn(
|
||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[11px] transition-colors',
|
||
value === o.value
|
||
? 'bg-surface-1 text-foreground'
|
||
: 'text-muted-foreground hover:bg-surface-1/50 hover:text-foreground',
|
||
)}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate">{labelWithinGroup(o.label, group.label)}</span>
|
||
</button>
|
||
))}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
{footerAction && (
|
||
<div className="border-t border-border/20 p-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
close();
|
||
footerAction.onClick();
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-[11px] text-muted-foreground transition-colors hover:bg-surface-1/50 hover:text-foreground"
|
||
>
|
||
<Plus className="shrink-0" size={11} />
|
||
{footerAction.label}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|