mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
shell-redesign V1 attempt-3 Phase 5 P5-3: subscription toggle wiring (SC-29 allowlist exception)
Closes Phase 4 P4-6 deferral. Wires the For You subscription affordance
from a static visual list (Phase 3) into live ConfigStore-backed toggles
that filter the feed. Per for-you-feed.md L130-L151 and SC-16
(subscriptions are settings-shaped, NOT feed-shaped — primary UX of
/for-you stays the FEED in the center; subscriptions live in the
explore sidebar as a small on-demand list).
SC-29 EXCEPTION DECLARED in Phase 5 ACK §5 DRIFT P5-D2 (orch-authorized
at GO retry; same scope as Phase 4 ConfigStore exception). 5 new
allowlist keys added LOCKSTEP daemon settings-store.ts + cli
config-store.ts; NO schema migrations / new endpoints / event types.
Daemon byte-equivalent SC-29 hard line preserved otherwise.
NEW ConfigStore allowlist keys (defaults per for-you-feed.md L144-L151):
- feed.subscriptions.action_required → true (FORCED ON; cannot be
disabled via UI toggle per L145 — load-bearing human-gate items)
- feed.subscriptions.approvals → true
- feed.subscriptions.shipped → true
- feed.subscriptions.progress → true
- feed.subscriptions.audit_log → false (verbose; opt-in)
NEW packages/ui/src/hooks/useFeedSubscriptions.ts:
- useFeedSubscriptions() reads the 5 keys from /api/config (via existing
useSettings) and exposes { state, toggle, isMutating, unavailable }.
- toggle(key) writes via existing useSetSetting; action_required is not
in the toggle key union (forced ON).
- isCardKindSubscribed(kind, state) maps FeedCardKind → subscription
state field for Feed.tsx filtering.
NEW packages/ui/src/components/for-you/SubscriptionToggleList.tsx:
- Settings-shaped row list per for-you-feed.md L134-L140.
- 5 rows; action-required is forced ON with "forced ON" label (no
interactive button); other 4 render aria-checked toggle buttons that
call toggle() on click.
- Settings unavailable (legacy v0.2.0 daemon) → renders defaults +
CLI-fallback hint pointing at `rig config set feed.subscriptions.<kind>
true|false`.
REFACTORED packages/ui/src/components/Explorer.tsx:
- For You surface body delegates to <SubscriptionToggleList />; replaces
the static visual list from Phase 3.
REFACTORED packages/ui/src/components/for-you/Feed.tsx:
- Feed cards filtered by subscription state FIRST (via
isCardKindSubscribed), THEN sliced to HISTORY_LIMIT, THEN lens-filtered.
- action-required cards always pass (forced ON); observation cards pass
only when audit_log is ON; etc.
NEW packages/ui/test/p5-subscription-toggle.test.tsx (6 tests):
- isCardKindSubscribed contract (action-required always; observation
gated by auditLog; shipped gated by shipped).
- SubscriptionToggleList renders 5 rows; action-required has no
toggle button (forced ON); others have interactive buttons.
- Toggle click POSTs to /api/config/feed.subscriptions.audit_log with
flipped value "true".
- Settings unavailable (404) renders CLI hint + defaults.
Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- pnpm -F @openrig/daemon exec tsc --noEmit: clean
- pnpm -F @openrig/cli exec tsc --noEmit: clean
- vitest full UI suite: 654/656 PASS (pre-existing library-review
Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
919a428ee9
commit
4e67cf660c
8 files changed
+554
-32
No files matched your search
@@ -54,6 +54,19 @@ export interface RiggedConfig {
|
||||
advisorSession: string;
|
||||
operatorSession: string;
|
||||
};
|
||||
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles per
|
||||
// for-you-feed.md L144–L151. SC-29 EXCEPTION declared in Phase 5
|
||||
// dispatch ACK §5 DRIFT P5-D2: same scope as Phase 4 (allowlist-only;
|
||||
// no migrations / new endpoints / event types).
|
||||
feed: {
|
||||
subscriptions: {
|
||||
actionRequired: boolean;
|
||||
approvals: boolean;
|
||||
shipped: boolean;
|
||||
progress: boolean;
|
||||
auditLog: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
@@ -88,6 +101,20 @@ const DEFAULTS = {
|
||||
advisorSession: "advisor-lead@openrig-velocity",
|
||||
operatorSession: "",
|
||||
},
|
||||
// V1 Phase 5 P5-3 — feed subscription defaults per for-you-feed.md
|
||||
// L144–L151. action_required forced ON in UI (load-bearing
|
||||
// human-gate items per L145; cannot be disabled); approvals/
|
||||
// shipped/progress default ON; audit_log default OFF (verbose;
|
||||
// opt-in for triage runs).
|
||||
feed: {
|
||||
subscriptions: {
|
||||
actionRequired: true,
|
||||
approvals: true,
|
||||
shipped: true,
|
||||
progress: true,
|
||||
auditLog: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const VALID_KEYS = [
|
||||
@@ -111,6 +138,12 @@ export const VALID_KEYS = [
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
"agents.advisor_session",
|
||||
"agents.operator_session",
|
||||
// V1 Phase 5 P5-3 SC-29 exception — allowlist-only additions.
|
||||
"feed.subscriptions.action_required",
|
||||
"feed.subscriptions.approvals",
|
||||
"feed.subscriptions.shipped",
|
||||
"feed.subscriptions.progress",
|
||||
"feed.subscriptions.audit_log",
|
||||
] as const;
|
||||
|
||||
export type ValidKey = typeof VALID_KEYS[number];
|
||||
@@ -138,6 +171,11 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST", legacy: "RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION", legacy: "RIGGED_AGENTS_ADVISOR_SESSION" },
|
||||
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION", legacy: "RIGGED_AGENTS_OPERATOR_SESSION" },
|
||||
"feed.subscriptions.action_required": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", legacy: "RIGGED_FEED_SUBSCRIPTIONS_ACTION_REQUIRED" },
|
||||
"feed.subscriptions.approvals": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS", legacy: "RIGGED_FEED_SUBSCRIPTIONS_APPROVALS" },
|
||||
"feed.subscriptions.shipped": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", legacy: "RIGGED_FEED_SUBSCRIPTIONS_SHIPPED" },
|
||||
"feed.subscriptions.progress": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS", legacy: "RIGGED_FEED_SUBSCRIPTIONS_PROGRESS" },
|
||||
"feed.subscriptions.audit_log": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG", legacy: "RIGGED_FEED_SUBSCRIPTIONS_AUDIT_LOG" },
|
||||
};
|
||||
|
||||
// Maps dotted-string config keys to the camelCase RiggedConfig path.
|
||||
@@ -164,6 +202,11 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
"agents.advisor_session": ["agents", "advisorSession"],
|
||||
"agents.operator_session": ["agents", "operatorSession"],
|
||||
"feed.subscriptions.action_required": ["feed", "subscriptions", "actionRequired"],
|
||||
"feed.subscriptions.approvals": ["feed", "subscriptions", "approvals"],
|
||||
"feed.subscriptions.shipped": ["feed", "subscriptions", "shipped"],
|
||||
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
|
||||
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
|
||||
};
|
||||
|
||||
function isValidKey(key: string): key is ValidKey {
|
||||
@@ -304,6 +347,15 @@ export class ConfigStore {
|
||||
advisorSession: v("agents.advisor_session") as string,
|
||||
operatorSession: v("agents.operator_session") as string,
|
||||
},
|
||||
feed: {
|
||||
subscriptions: {
|
||||
actionRequired: v("feed.subscriptions.action_required") as boolean,
|
||||
approvals: v("feed.subscriptions.approvals") as boolean,
|
||||
shipped: v("feed.subscriptions.shipped") as boolean,
|
||||
progress: v("feed.subscriptions.progress") as boolean,
|
||||
auditLog: v("feed.subscriptions.audit_log") as boolean,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@ export const SETTINGS_VALID_KEYS = [
|
||||
// endpoints / event types.
|
||||
"agents.advisor_session",
|
||||
"agents.operator_session",
|
||||
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles per
|
||||
// for-you-feed.md L144–L151. SC-29 EXCEPTION declared in Phase 5
|
||||
// dispatch ACK §5 (DRIFT P5-D2; same scope as Phase 4: allowlist-only;
|
||||
// no migrations / new endpoints / event types). action_required is
|
||||
// forced ON in the UI (cannot be toggled per L145); the key exists
|
||||
// for future operator override but is not surfaced as a toggle in V1.
|
||||
"feed.subscriptions.action_required",
|
||||
"feed.subscriptions.approvals",
|
||||
"feed.subscriptions.shipped",
|
||||
"feed.subscriptions.progress",
|
||||
"feed.subscriptions.audit_log",
|
||||
] as const;
|
||||
|
||||
export type SettingsValidKey = typeof SETTINGS_VALID_KEYS[number];
|
||||
@@ -77,6 +88,11 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy: string }> = {
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST", legacy: "RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION", legacy: "RIGGED_AGENTS_ADVISOR_SESSION" },
|
||||
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION", legacy: "RIGGED_AGENTS_OPERATOR_SESSION" },
|
||||
"feed.subscriptions.action_required": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", legacy: "RIGGED_FEED_SUBSCRIPTIONS_ACTION_REQUIRED" },
|
||||
"feed.subscriptions.approvals": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS", legacy: "RIGGED_FEED_SUBSCRIPTIONS_APPROVALS" },
|
||||
"feed.subscriptions.shipped": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", legacy: "RIGGED_FEED_SUBSCRIPTIONS_SHIPPED" },
|
||||
"feed.subscriptions.progress": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS", legacy: "RIGGED_FEED_SUBSCRIPTIONS_PROGRESS" },
|
||||
"feed.subscriptions.audit_log": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG", legacy: "RIGGED_FEED_SUBSCRIPTIONS_AUDIT_LOG" },
|
||||
};
|
||||
|
||||
const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
@@ -99,6 +115,11 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
"agents.advisor_session": ["agents", "advisorSession"],
|
||||
"agents.operator_session": ["agents", "operatorSession"],
|
||||
"feed.subscriptions.action_required": ["feed", "subscriptions", "actionRequired"],
|
||||
"feed.subscriptions.approvals": ["feed", "subscriptions", "approvals"],
|
||||
"feed.subscriptions.shipped": ["feed", "subscriptions", "shipped"],
|
||||
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
|
||||
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
|
||||
};
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
@@ -184,6 +205,16 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
// Operator default empty per L84 ("not configured").
|
||||
case "agents.advisor_session": return "advisor-lead@openrig-velocity";
|
||||
case "agents.operator_session": return "";
|
||||
// V1 Phase 5 P5-3 — For You feed subscription defaults per
|
||||
// for-you-feed.md L144–L151. action_required is forced ON in the UI
|
||||
// (cannot be disabled per L145 — load-bearing human-gate items);
|
||||
// approvals/shipped/progress default ON; audit_log default OFF
|
||||
// (verbose; opt-in for triage runs).
|
||||
case "feed.subscriptions.action_required": return true;
|
||||
case "feed.subscriptions.approvals": return true;
|
||||
case "feed.subscriptions.shipped": return true;
|
||||
case "feed.subscriptions.progress": return true;
|
||||
case "feed.subscriptions.audit_log": return false;
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { EmptyState } from "./ui/empty-state.js";
|
||||
import { ProjectTreeView } from "./project/ProjectTreeView.js";
|
||||
import { SpecsTreeView } from "./specs/SpecsTreeView.js";
|
||||
import { TopologyTreeView } from "./topology/TopologyTreeView.js";
|
||||
import { SubscriptionToggleList } from "./for-you/SubscriptionToggleList.js";
|
||||
|
||||
import type { DrawerSelection } from "./SharedDetailDrawer.js";
|
||||
|
||||
@@ -518,37 +519,14 @@ function SurfaceBody({
|
||||
// Subscription affordance — settings-shaped surface per for-you-feed.md L134-L140.
|
||||
// The PRIMARY UX of /for-you is the FEED in the center; subscriptions live
|
||||
// here as a small on-demand list. NOT dominating.
|
||||
//
|
||||
// V1 attempt-3 Phase 5 P5-3: live ConfigStore-wired toggles via
|
||||
// SubscriptionToggleList. action_required is forced ON; the other 4
|
||||
// toggle interactively. Settings endpoint unreachable → canonical
|
||||
// defaults rendered with CLI-fallback hint.
|
||||
return (
|
||||
<div data-testid="explorer-for-you-subscriptions" className="flex-1 overflow-y-auto py-3 px-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant mb-2">
|
||||
Subscriptions
|
||||
</div>
|
||||
<ul className="space-y-1 font-mono text-xs">
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Action required</span>
|
||||
<span className="text-[9px] text-on-surface-variant">forced ON</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Approvals</span>
|
||||
<span className="text-[9px] text-success">on</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Feature ships</span>
|
||||
<span className="text-[9px] text-success">on</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Slice progress</span>
|
||||
<span className="text-[9px] text-success">on</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Audit log</span>
|
||||
<span className="text-[9px] text-on-surface-variant">off</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-3 font-mono text-[9px] text-on-surface-variant italic">
|
||||
Per-subscription toggles wire in Phase 4. V1 list reads canonical
|
||||
defaults from for-you-feed.md L142–L150.
|
||||
</p>
|
||||
<SubscriptionToggleList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
// PRIMARY UX = the feed itself. Subscriptions are NOT dominating
|
||||
// (per for-you-feed.md L134-L140 LOAD-BEARING SC-16) — the explore
|
||||
// sidebar holds subscription affordances; the feed is the centerpiece.
|
||||
//
|
||||
// V1 attempt-3 Phase 5 P5-3: feed cards are filtered by the live
|
||||
// subscription state from /api/config (5 feed.subscriptions.* keys).
|
||||
// action_required cards are always visible (forced ON per L145);
|
||||
// observation cards visible only when audit_log is ON (default OFF).
|
||||
// Lens chips remain a transient ad-hoc filter on top of subscription-
|
||||
// filtered cards.
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
@@ -10,6 +17,10 @@ import { SectionHeader } from "../ui/section-header.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { useActivityFeed } from "../../hooks/useActivityFeed.js";
|
||||
import { classifyFeed, type FeedCardKind } from "../../lib/feed-classifier.js";
|
||||
import {
|
||||
useFeedSubscriptions,
|
||||
isCardKindSubscribed,
|
||||
} from "../../hooks/useFeedSubscriptions.js";
|
||||
import { FeedCard } from "./FeedCard.js";
|
||||
|
||||
const LENS_CHIPS: Array<{ id: FeedCardKind | "all"; label: string }> = [
|
||||
@@ -26,12 +37,18 @@ const HISTORY_LIMIT = 50; // per for-you-feed.md L182
|
||||
export function Feed() {
|
||||
const { events } = useActivityFeed();
|
||||
const [lens, setLens] = useState<FeedCardKind | "all">("all");
|
||||
const subs = useFeedSubscriptions();
|
||||
|
||||
const cards = useMemo(() => {
|
||||
const classified = classifyFeed(events);
|
||||
const sliced = classified.slice(0, HISTORY_LIMIT);
|
||||
// Filter by subscription state FIRST so the feed honors operator
|
||||
// configuration, then apply the transient lens filter on top.
|
||||
const subscribed = classified.filter((c) =>
|
||||
isCardKindSubscribed(c.kind, subs.state),
|
||||
);
|
||||
const sliced = subscribed.slice(0, HISTORY_LIMIT);
|
||||
return lens === "all" ? sliced : sliced.filter((c) => c.kind === lens);
|
||||
}, [events, lens]);
|
||||
}, [events, lens, subs.state]);
|
||||
|
||||
return (
|
||||
<div data-testid="for-you-feed" className="mx-auto w-full max-w-[720px] px-6 py-8">
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// V1 attempt-3 Phase 5 P5-3 — For You subscription toggle surface.
|
||||
//
|
||||
// Renders the 5 subscriptions per for-you-feed.md L144–L151 as a small
|
||||
// settings-shaped list (NOT a feed-shaped UX per L134-L140 LOAD-BEARING
|
||||
// SC-16). Each non-forced row toggles on click and writes the matching
|
||||
// feed.subscriptions.* ConfigStore key. action_required is forced ON
|
||||
// (L145 — load-bearing human-gate items cannot be disabled) and renders
|
||||
// as a disabled-looking row with "forced ON" label.
|
||||
|
||||
import { useFeedSubscriptions } from "../../hooks/useFeedSubscriptions.js";
|
||||
|
||||
interface ToggleRow {
|
||||
toggleKey: "approvals" | "shipped" | "progress" | "auditLog" | null;
|
||||
label: string;
|
||||
description: string;
|
||||
forced?: boolean;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
const ROWS: ToggleRow[] = [
|
||||
{
|
||||
toggleKey: null,
|
||||
label: "Action required",
|
||||
description: "Items the human must act on",
|
||||
forced: true,
|
||||
testId: "subscription-toggle-action-required",
|
||||
},
|
||||
{
|
||||
toggleKey: "approvals",
|
||||
label: "Approvals",
|
||||
description: "Closeout-pending-ratify items",
|
||||
testId: "subscription-toggle-approvals",
|
||||
},
|
||||
{
|
||||
toggleKey: "shipped",
|
||||
label: "Feature ships",
|
||||
description: "Slice / mission deliveries + git tag landings",
|
||||
testId: "subscription-toggle-shipped",
|
||||
},
|
||||
{
|
||||
toggleKey: "progress",
|
||||
label: "Slice progress",
|
||||
description: "Compact progress rollups",
|
||||
testId: "subscription-toggle-progress",
|
||||
},
|
||||
{
|
||||
toggleKey: "auditLog",
|
||||
label: "Audit log",
|
||||
description: "Verbose stream + watchdog observations",
|
||||
testId: "subscription-toggle-audit-log",
|
||||
},
|
||||
];
|
||||
|
||||
export function SubscriptionToggleList() {
|
||||
const { state, toggle, isMutating, unavailable } = useFeedSubscriptions();
|
||||
|
||||
return (
|
||||
<div data-testid="subscription-toggle-list" className="font-mono text-xs">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant mb-2">
|
||||
Subscriptions
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{ROWS.map((row) => {
|
||||
// Resolve current value for this row.
|
||||
const value =
|
||||
row.toggleKey === null
|
||||
? state.actionRequired // always true in V1
|
||||
: state[row.toggleKey];
|
||||
const interactive = !row.forced && !unavailable;
|
||||
return (
|
||||
<li
|
||||
key={row.testId}
|
||||
data-testid={row.testId}
|
||||
data-on={value ? "true" : "false"}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-stone-900 truncate">{row.label}</div>
|
||||
<div className="font-mono text-[9px] text-on-surface-variant truncate">
|
||||
{row.description}
|
||||
</div>
|
||||
</div>
|
||||
{row.forced ? (
|
||||
<span className="font-mono text-[9px] uppercase tracking-wide text-on-surface-variant shrink-0">
|
||||
forced ON
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
disabled={!interactive || isMutating}
|
||||
data-testid={`${row.testId}-button`}
|
||||
onClick={() => row.toggleKey && toggle(row.toggleKey)}
|
||||
className={
|
||||
"shrink-0 px-2 py-0.5 border font-mono text-[9px] uppercase tracking-wide " +
|
||||
(value
|
||||
? "border-success text-success"
|
||||
: "border-stone-300 text-on-surface-variant") +
|
||||
(interactive
|
||||
? " hover:bg-stone-100/60"
|
||||
: " opacity-60 cursor-not-allowed")
|
||||
}
|
||||
>
|
||||
{value ? "on" : "off"}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{unavailable ? (
|
||||
<p
|
||||
data-testid="subscription-toggle-unavailable"
|
||||
className="mt-3 font-mono text-[9px] text-on-surface-variant italic"
|
||||
>
|
||||
Settings endpoint unreachable (legacy daemon < v0.3.0). Toggles render the
|
||||
canonical defaults; configure via CLI:
|
||||
<code className="ml-1 text-stone-700">
|
||||
rig config set feed.subscriptions.<kind> true|false
|
||||
</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription state hook.
|
||||
//
|
||||
// Reads the 5 feed.subscriptions.* allowlist keys from /api/config and
|
||||
// provides a toggle function that writes back via /api/config/<key>.
|
||||
// Per for-you-feed.md L144-L151:
|
||||
// - action_required is FORCED ON (cannot be disabled per L145; the
|
||||
// UI surface this hook drives never renders an interactive toggle
|
||||
// for that key).
|
||||
// - approvals / shipped / progress default ON.
|
||||
// - audit_log default OFF.
|
||||
//
|
||||
// SC-29 exception scope (declared in Phase 5 ACK §5 DRIFT P5-D2): same
|
||||
// as Phase 4 ConfigStore allowlist exception; allowlist-only additions.
|
||||
|
||||
import { useSettings, useSetSetting } from "./useSettings.js";
|
||||
import type { FeedCardKind } from "../lib/feed-classifier.js";
|
||||
|
||||
export interface FeedSubscriptionState {
|
||||
actionRequired: boolean;
|
||||
approvals: boolean;
|
||||
shipped: boolean;
|
||||
progress: boolean;
|
||||
auditLog: boolean;
|
||||
}
|
||||
|
||||
export type FeedSubscriptionToggleKey =
|
||||
| "approvals"
|
||||
| "shipped"
|
||||
| "progress"
|
||||
| "auditLog";
|
||||
|
||||
const TOGGLE_KEY_TO_CONFIG_KEY: Record<FeedSubscriptionToggleKey, string> = {
|
||||
approvals: "feed.subscriptions.approvals",
|
||||
shipped: "feed.subscriptions.shipped",
|
||||
progress: "feed.subscriptions.progress",
|
||||
auditLog: "feed.subscriptions.audit_log",
|
||||
};
|
||||
|
||||
const DEFAULTS: FeedSubscriptionState = {
|
||||
actionRequired: true,
|
||||
approvals: true,
|
||||
shipped: true,
|
||||
progress: true,
|
||||
auditLog: false,
|
||||
};
|
||||
|
||||
function readBool(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
if (value === "true" || value === "1") return true;
|
||||
if (value === "false" || value === "0") return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface UseFeedSubscriptionsResult {
|
||||
state: FeedSubscriptionState;
|
||||
/** Toggle a non-forced subscription. action_required is forced ON and
|
||||
* cannot be toggled — calling toggle("actionRequired") is intentionally
|
||||
* not part of the API. */
|
||||
toggle: (key: FeedSubscriptionToggleKey) => void;
|
||||
/** True when the underlying setSetting mutation is in flight. */
|
||||
isMutating: boolean;
|
||||
/** True when the daemon does not expose /api/config (legacy v0.2.0). */
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export function useFeedSubscriptions(): UseFeedSubscriptionsResult {
|
||||
const { data, error } = useSettings();
|
||||
const setSetting = useSetSetting();
|
||||
|
||||
const settings = data?.settings as Record<string, { value: unknown }> | undefined;
|
||||
const unavailable = !!error || !settings;
|
||||
|
||||
const state: FeedSubscriptionState = unavailable
|
||||
? DEFAULTS
|
||||
: {
|
||||
actionRequired: readBool(
|
||||
settings?.["feed.subscriptions.action_required"]?.value,
|
||||
DEFAULTS.actionRequired,
|
||||
),
|
||||
approvals: readBool(
|
||||
settings?.["feed.subscriptions.approvals"]?.value,
|
||||
DEFAULTS.approvals,
|
||||
),
|
||||
shipped: readBool(
|
||||
settings?.["feed.subscriptions.shipped"]?.value,
|
||||
DEFAULTS.shipped,
|
||||
),
|
||||
progress: readBool(
|
||||
settings?.["feed.subscriptions.progress"]?.value,
|
||||
DEFAULTS.progress,
|
||||
),
|
||||
auditLog: readBool(
|
||||
settings?.["feed.subscriptions.audit_log"]?.value,
|
||||
DEFAULTS.auditLog,
|
||||
),
|
||||
};
|
||||
|
||||
const toggle = (toggleKey: FeedSubscriptionToggleKey) => {
|
||||
const configKey = TOGGLE_KEY_TO_CONFIG_KEY[toggleKey];
|
||||
const current = state[toggleKey];
|
||||
setSetting.mutate({
|
||||
key: configKey as Parameters<typeof setSetting.mutate>[0]["key"],
|
||||
value: current ? "false" : "true",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
toggle,
|
||||
isMutating: setSetting.isPending,
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a feed card kind to its subscription state field. Used by the
|
||||
* Feed component to filter out cards whose subscription is OFF. */
|
||||
export function isCardKindSubscribed(
|
||||
kind: FeedCardKind,
|
||||
state: FeedSubscriptionState,
|
||||
): boolean {
|
||||
switch (kind) {
|
||||
case "action-required":
|
||||
return state.actionRequired; // always true in V1 (forced ON)
|
||||
case "approval":
|
||||
return state.approvals;
|
||||
case "shipped":
|
||||
return state.shipped;
|
||||
case "progress":
|
||||
return state.progress;
|
||||
case "observation":
|
||||
return state.auditLog;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,18 @@ export type SettingsKey =
|
||||
// Preview Terminal v0 (PL-018) keys.
|
||||
| "ui.preview.refresh_interval_seconds"
|
||||
| "ui.preview.max_pins"
|
||||
| "ui.preview.default_lines";
|
||||
| "ui.preview.default_lines"
|
||||
// V1 Phase 4 ConfigStore allowlist exception (advisor/operator seats).
|
||||
| "agents.advisor_session" | "agents.operator_session"
|
||||
// V1 Phase 5 P5-3 ConfigStore allowlist exception (For You feed
|
||||
// subscription toggles per for-you-feed.md L144-L151). Same SC-29
|
||||
// exception scope as Phase 4 (allowlist-only; no schema migrations
|
||||
// / new endpoints / event types).
|
||||
| "feed.subscriptions.action_required"
|
||||
| "feed.subscriptions.approvals"
|
||||
| "feed.subscriptions.shipped"
|
||||
| "feed.subscriptions.progress"
|
||||
| "feed.subscriptions.audit_log";
|
||||
|
||||
export interface SettingsResponse {
|
||||
settings: Record<SettingsKey, ResolvedSetting>;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// V1 attempt-3 Phase 5 P5-3 — Subscription toggle wiring (SC-29 allowlist
|
||||
// exception same scope as Phase 4).
|
||||
//
|
||||
// Coverage:
|
||||
// - SubscriptionToggleList renders 5 rows; action_required is forced ON
|
||||
// (no toggle button; "forced ON" label).
|
||||
// - Toggle click → useSetSetting POSTs to /api/config/<key> with the
|
||||
// correct value.
|
||||
// - Feed filters cards by subscription state (audit_log OFF → no
|
||||
// observation cards rendered; turning audit_log ON surfaces them).
|
||||
// - Settings unavailable (legacy daemon) → defaults rendered + CLI hint.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
isCardKindSubscribed,
|
||||
type FeedSubscriptionState,
|
||||
} from "../src/hooks/useFeedSubscriptions.js";
|
||||
import { SubscriptionToggleList } from "../src/components/for-you/SubscriptionToggleList.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function settingsResponse(values: Partial<Record<string, string | boolean>>) {
|
||||
return {
|
||||
settings: Object.fromEntries(
|
||||
Object.entries(values).map(([k, v]) => [
|
||||
k,
|
||||
{ value: v, source: "default", defaultValue: v },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function withQueryClient(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("isCardKindSubscribed (P5-3 filter contract)", () => {
|
||||
const state: FeedSubscriptionState = {
|
||||
actionRequired: true,
|
||||
approvals: true,
|
||||
shipped: false,
|
||||
progress: true,
|
||||
auditLog: false,
|
||||
};
|
||||
it("action-required cards always pass (forced ON)", () => {
|
||||
expect(isCardKindSubscribed("action-required", state)).toBe(true);
|
||||
});
|
||||
it("observation cards pass only when auditLog is ON", () => {
|
||||
expect(isCardKindSubscribed("observation", state)).toBe(false);
|
||||
expect(
|
||||
isCardKindSubscribed("observation", { ...state, auditLog: true }),
|
||||
).toBe(true);
|
||||
});
|
||||
it("shipped cards pass only when shipped is ON", () => {
|
||||
expect(isCardKindSubscribed("shipped", state)).toBe(false);
|
||||
expect(isCardKindSubscribed("shipped", { ...state, shipped: true })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionToggleList P5-3 wiring", () => {
|
||||
it("renders all 5 rows with action-required FORCED ON (no interactive button)", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/api/config")) {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
settingsResponse({
|
||||
"feed.subscriptions.action_required": true,
|
||||
"feed.subscriptions.approvals": true,
|
||||
"feed.subscriptions.shipped": true,
|
||||
"feed.subscriptions.progress": true,
|
||||
"feed.subscriptions.audit_log": false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return new Response("[]");
|
||||
});
|
||||
const { findByTestId, queryByTestId, container } = withQueryClient(
|
||||
<SubscriptionToggleList />,
|
||||
);
|
||||
expect(await findByTestId("subscription-toggle-list")).toBeTruthy();
|
||||
// All 5 rows.
|
||||
expect(container.querySelector("[data-testid='subscription-toggle-action-required']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='subscription-toggle-approvals']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='subscription-toggle-shipped']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='subscription-toggle-progress']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='subscription-toggle-audit-log']")).toBeTruthy();
|
||||
// action-required is FORCED ON — no button.
|
||||
expect(queryByTestId("subscription-toggle-action-required-button")).toBeNull();
|
||||
// Other 4 are interactive (have buttons).
|
||||
expect(container.querySelector("[data-testid='subscription-toggle-approvals-button']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggle click POSTs to /api/config/<key> with flipped value", async () => {
|
||||
let postedKey = "";
|
||||
let postedBody = "";
|
||||
mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
// POST to /api/config/<key> takes precedence over GET /api/config.
|
||||
const postMatch = url.match(/\/api\/config\/(.+)/);
|
||||
if (postMatch && init?.method === "POST") {
|
||||
postedKey = decodeURIComponent(postMatch[1]!);
|
||||
postedBody = (init?.body as string) ?? "";
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, resolved: { value: true, source: "file", defaultValue: false } }),
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/api/config")) {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
settingsResponse({
|
||||
"feed.subscriptions.audit_log": false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return new Response("[]");
|
||||
});
|
||||
const { findByTestId } = withQueryClient(<SubscriptionToggleList />);
|
||||
// Wait for the button to be enabled (settings resolved → unavailable=false).
|
||||
const auditButton = await waitFor(async () => {
|
||||
const btn = await findByTestId("subscription-toggle-audit-log-button");
|
||||
if ((btn as HTMLButtonElement).disabled) {
|
||||
throw new Error("button still disabled");
|
||||
}
|
||||
return btn;
|
||||
});
|
||||
fireEvent.click(auditButton);
|
||||
await waitFor(() => {
|
||||
expect(postedKey).toBe("feed.subscriptions.audit_log");
|
||||
});
|
||||
// Was off → toggling sets to "true".
|
||||
expect(postedBody).toContain("true");
|
||||
});
|
||||
|
||||
it("settings unavailable (legacy daemon) renders defaults + CLI hint", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/api/config")) {
|
||||
return new Response("not implemented", { status: 404 });
|
||||
}
|
||||
return new Response("[]");
|
||||
});
|
||||
const { findByTestId } = withQueryClient(<SubscriptionToggleList />);
|
||||
expect(await findByTestId("subscription-toggle-unavailable")).toBeTruthy();
|
||||
// Defaults visible: action_required ON, audit_log OFF.
|
||||
expect(
|
||||
(await findByTestId("subscription-toggle-action-required")).getAttribute(
|
||||
"data-on",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(
|
||||
(await findByTestId("subscription-toggle-audit-log")).getAttribute(
|
||||
"data-on",
|
||||
),
|
||||
).toBe("false");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user