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:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 17:45:40 -07:00
1 parent 919a428ee9
commit 4e67cf660c
8 files changed
+554 -32

No files matched your search

+52
View File
@@ -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 "";
}
}
+7 -29
View File
@@ -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>
);
}
+19 -2
View File
@@ -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 &lt; v0.3.0). Toggles render the
canonical defaults; configure via CLI:
<code className="ml-1 text-stone-700">
rig config set feed.subscriptions.&lt;kind&gt; 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;
}
}
+12 -1
View File
@@ -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");
});
});