@pellux/goodvibes-tui 1.16.1 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -9
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +5490 -1806
- package/package.json +7 -7
- package/src/cli/completions/generate.ts +5 -4
- package/src/cli/config-overrides.ts +51 -11
- package/src/cli/doctor.ts +4 -5
- package/src/cli/entrypoint.ts +13 -8
- package/src/cli/help.ts +7 -6
- package/src/cli/launch-auto-update.ts +214 -0
- package/src/cli/management-commands.ts +82 -15
- package/src/cli/management.ts +0 -2
- package/src/cli/parser.ts +5 -0
- package/src/cli/status.ts +13 -0
- package/src/cli/surface-command.ts +5 -5
- package/src/cli/tui-startup.ts +60 -97
- package/src/cli/types.ts +2 -0
- package/src/config/index.ts +1 -1
- package/src/config/tui-extension-settings.ts +33 -0
- package/src/core/composer-state.ts +1 -1
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-line-cache.ts +3 -3
- package/src/core/conversation-rendering.ts +18 -9
- package/src/core/conversation.ts +2 -2
- package/src/core/long-task-notifier.ts +7 -7
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +29 -6
- package/src/core/system-message-noise.ts +1 -1
- package/src/core/system-message-router.ts +6 -6
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +40 -1
- package/src/core/turn-event-wiring.ts +14 -8
- package/src/daemon/cli.ts +64 -58
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/service-commands.ts +5 -5
- package/src/export/cost-utils.ts +128 -6
- package/src/input/autocomplete.ts +2 -2
- package/src/input/command-registry.ts +55 -9
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/ci-runtime.ts +5 -1
- package/src/input/commands/codebase-runtime.ts +6 -6
- package/src/input/commands/cost-runtime.ts +1 -1
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/health-runtime.ts +2 -2
- package/src/input/commands/intelligence-runtime.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +1 -1
- package/src/input/commands/local-provider-runtime.ts +49 -10
- package/src/input/commands/local-runtime.ts +53 -5
- package/src/input/commands/memory.ts +2 -2
- package/src/input/commands/operator-panel-runtime.ts +5 -5
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/permissions-provenance.ts +1 -1
- package/src/input/commands/permissions-runtime.ts +99 -8
- package/src/input/commands/planning-runtime.ts +3 -3
- package/src/input/commands/platform-sandbox-runtime.ts +1 -1
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/provider-accounts-runtime.ts +71 -6
- package/src/input/commands/provider.ts +7 -5
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/recall-files-git.ts +13 -51
- package/src/input/commands/recall-files.ts +8 -11
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/relay-runtime.ts +1 -1
- package/src/input/commands/remote-runtime.ts +1 -1
- package/src/input/commands/runtime-services.ts +4 -4
- package/src/input/commands/schedule-runtime.ts +14 -7
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +2 -2
- package/src/input/commands/settings-sync-runtime.ts +2 -2
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +46 -68
- package/src/input/commands/subscription-runtime.ts +1 -1
- package/src/input/commands/update-runtime.ts +153 -96
- package/src/input/commands/work-plan-runtime.ts +1 -1
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +46 -27
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -2
- package/src/input/config-modal-types.ts +14 -3
- package/src/input/config-modal.ts +75 -13
- package/src/input/feature-unit-layout.ts +93 -82
- package/src/input/feed-context-factory.ts +2 -2
- package/src/input/handler-command-route.ts +2 -2
- package/src/input/handler-content-actions.ts +1 -1
- package/src/input/handler-feed-routes.ts +7 -7
- package/src/input/handler-feed.ts +4 -4
- package/src/input/handler-interactions.ts +3 -2
- package/src/input/handler-modal-routes.ts +12 -4
- package/src/input/handler-modal-token-routes.ts +1 -1
- package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
- package/src/input/handler-onboarding.ts +3 -2
- package/src/input/handler-picker-routes.ts +26 -13
- package/src/input/handler-shortcuts.ts +29 -6
- package/src/input/handler.ts +2 -2
- package/src/input/keybindings.ts +21 -3
- package/src/input/onboarding/onboarding-feature-units.ts +34 -27
- package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
- package/src/input/panel-integration-actions.ts +2 -2
- package/src/input/panel-mouse-geometry.ts +1 -1
- package/src/input/panel-paste-flood-guard.ts +1 -1
- package/src/input/provider-key-intake.ts +167 -0
- package/src/input/sandbox-exec-config.ts +2 -2
- package/src/input/selection-modal.ts +1 -1
- package/src/input/settings-modal-activation.ts +5 -0
- package/src/input/settings-modal-adjustment.ts +4 -31
- package/src/input/settings-modal-data.ts +52 -57
- package/src/input/settings-modal-mutations.ts +12 -68
- package/src/input/settings-modal-types.ts +27 -17
- package/src/input/settings-modal.ts +74 -55
- package/src/main.ts +90 -90
- package/src/panels/agent-inspector-shared.ts +4 -4
- package/src/panels/base-panel.ts +2 -2
- package/src/panels/builtin/agent.ts +4 -4
- package/src/panels/builtin/development.ts +4 -4
- package/src/panels/builtin/knowledge.ts +1 -1
- package/src/panels/builtin/operations.ts +83 -11
- package/src/panels/builtin/session.ts +5 -5
- package/src/panels/builtin/shared.ts +16 -4
- package/src/panels/builtin-modals.ts +59 -42
- package/src/panels/builtin-panels.ts +1 -1
- package/src/panels/cost-tracker-panel.ts +124 -7
- package/src/panels/diff-panel.ts +39 -6
- package/src/panels/eval-registry.ts +2 -2
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-deep-link.ts +1 -1
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +163 -11
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +73 -57
- package/src/panels/fleet-read-model.ts +84 -14
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-steer.ts +2 -2
- package/src/panels/fleet-stop.ts +41 -5
- package/src/panels/fleet-tabs.ts +15 -16
- package/src/panels/fleet-transcript.ts +5 -5
- package/src/panels/git-panel.ts +2 -2
- package/src/panels/index.ts +4 -1
- package/src/panels/local-auth-panel.ts +1 -1
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/hooks-modal.ts +1 -1
- package/src/panels/modals/keybindings-modal.ts +2 -2
- package/src/panels/modals/knowledge-modal.ts +1 -1
- package/src/panels/modals/marketplace-modal.ts +4 -4
- package/src/panels/modals/memory-modal.ts +153 -9
- package/src/panels/modals/modal-surface-helpers.ts +1 -1
- package/src/panels/modals/modal-theme.ts +1 -1
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/planning-modal.ts +2 -2
- package/src/panels/modals/plugins-modal.ts +1 -1
- package/src/panels/modals/policy-modal.ts +1 -1
- package/src/panels/modals/security-modal.ts +7 -1
- package/src/panels/modals/skills-modal.ts +1 -1
- package/src/panels/modals/work-plan-modal.ts +3 -3
- package/src/panels/notifications-feed.ts +127 -0
- package/src/panels/notifications-panel.ts +160 -0
- package/src/panels/panel-manager.ts +9 -9
- package/src/panels/plugins-panel.ts +4 -4
- package/src/panels/project-planning-answer-actions.ts +1 -1
- package/src/panels/provider-health-routes.ts +2 -2
- package/src/panels/skills-panel.ts +4 -5
- package/src/panels/token-budget-panel.ts +11 -3
- package/src/panels/types.ts +5 -5
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/permissions/broker-approval-card.ts +201 -0
- package/src/permissions/hunk-selection.ts +28 -4
- package/src/permissions/prompt.ts +244 -29
- package/src/renderer/autocomplete-overlay.ts +107 -54
- package/src/renderer/code-block.ts +2 -2
- package/src/renderer/compaction-history-modal.ts +1 -1
- package/src/renderer/compaction-preview.ts +1 -1
- package/src/renderer/compaction-quality.ts +1 -1
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/compositor.ts +2 -2
- package/src/renderer/config-modal.ts +1 -1
- package/src/renderer/conversation-overlays.ts +1 -1
- package/src/renderer/footer-tips.ts +3 -3
- package/src/renderer/hint-grammar.ts +1 -1
- package/src/renderer/modal-factory.ts +1 -1
- package/src/renderer/model-picker-overlay.ts +1 -1
- package/src/renderer/model-workspace.ts +7 -2
- package/src/renderer/panel-composite.ts +2 -2
- package/src/renderer/panel-workspace-bar.ts +1 -1
- package/src/renderer/process-indicator.ts +1 -1
- package/src/renderer/selection-modal-overlay.ts +1 -1
- package/src/renderer/session-picker-modal.ts +2 -2
- package/src/renderer/settings-modal-helpers.ts +23 -5
- package/src/renderer/settings-modal.ts +129 -39
- package/src/renderer/shell-surface.ts +6 -0
- package/src/renderer/terminal-bg-probe.ts +1 -1
- package/src/renderer/theme.ts +3 -3
- package/src/renderer/tool-call.ts +13 -7
- package/src/renderer/tool-result-summary.ts +1 -1
- package/src/renderer/turn-injection.ts +7 -6
- package/src/renderer/ui-factory.ts +38 -1
- package/src/runtime/bootstrap-command-context.ts +7 -2
- package/src/runtime/bootstrap-command-parts.ts +9 -5
- package/src/runtime/bootstrap-core.ts +41 -41
- package/src/runtime/bootstrap-hook-bridge.ts +1 -1
- package/src/runtime/bootstrap-shell.ts +13 -5
- package/src/runtime/bootstrap.ts +34 -32
- package/src/runtime/channel-composition.ts +46 -0
- package/src/runtime/code-index-services.ts +3 -4
- package/src/runtime/daemon-attach-notices.ts +120 -0
- package/src/runtime/durability-services.ts +90 -0
- package/src/runtime/embedded-service-factories.ts +40 -0
- package/src/runtime/feature-settings.ts +147 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +6 -21
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/legacy-daemon-migration.ts +29 -19
- package/src/runtime/memory-fold.ts +1 -1
- package/src/runtime/notification-dispatch.ts +110 -0
- package/src/runtime/onboarding/apply.ts +3 -29
- package/src/runtime/onboarding/fast-path.ts +39 -6
- package/src/runtime/onboarding/provider-key-capture.ts +126 -0
- package/src/runtime/onboarding/snapshot.ts +0 -1
- package/src/runtime/onboarding/types.ts +9 -3
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/provider-fallback.ts +55 -0
- package/src/runtime/provider-optimizer-wiring.ts +55 -0
- package/src/runtime/release-artifacts.ts +19 -103
- package/src/runtime/resume-notice.ts +1 -1
- package/src/runtime/services.ts +113 -113
- package/src/runtime/session-continuity-hints.ts +38 -0
- package/src/runtime/surface-feature-flags.ts +40 -9
- package/src/runtime/terminal-output-guard.ts +2 -2
- package/src/runtime/trust/workspace-registration.ts +10 -2
- package/src/runtime/trust/workspace-trust.ts +58 -31
- package/src/runtime/ui-services.ts +6 -1
- package/src/runtime/update-check.ts +15 -69
- package/src/runtime/workstream-services.ts +2 -2
- package/src/shell/blocking-input.ts +108 -132
- package/src/shell/recovery-input-helpers.ts +53 -166
- package/src/shell/ui-openers.ts +38 -6
- package/src/utils/format-duration.ts +1 -1
- package/src/verification/verification-ledger.ts +21 -4
- package/src/version.ts +1 -1
- package/src/input/commands/flags-runtime.ts +0 -328
|
@@ -6,42 +6,83 @@ import type {
|
|
|
6
6
|
ConfigModalSurface,
|
|
7
7
|
ConfigModalView,
|
|
8
8
|
} from '../../input/config-modal-types.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { describeOriginPosture, generateQrMatrix, renderQrToString, type OriginPosture, type PairingHandoffOfferKind } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
10
|
+
import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../../core/pairing-offers.ts';
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// command instead. `getConnectionInfo` is a lazy thunk (loaded once on first
|
|
18
|
-
// buildView, cached) so a missing daemon home degrades honestly rather than
|
|
19
|
-
// throwing at modal-registration time.
|
|
13
|
+
// Device Pairing config-modal surface. Shows the web-app origin, the per-device
|
|
14
|
+
// token's name, a masked token/deep-link pair, the offer set with plain-language
|
|
15
|
+
// consequences, and a scannable QR of the `#pair=<token>` deep link — a camera
|
|
16
|
+
// scan opens the web app already signed in. No raw JSON connection blob is ever
|
|
17
|
+
// encoded.
|
|
20
18
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
19
|
+
// `getConnectionInfo` is a lazy thunk (loaded once on first buildView, cached):
|
|
20
|
+
// each modal open mints its OWN named per-device token, so a missing pairing
|
|
21
|
+
// service degrades honestly to an "unavailable" state instead of throwing at
|
|
22
|
+
// registration time. "new device token" clears the cache and re-pulls to mint a
|
|
23
|
+
// fresh token + QR in place — non-destructive now, because a freshly minted
|
|
24
|
+
// token no longer rotates a shared secret out from under a live device; old
|
|
25
|
+
// tokens stay valid until explicitly revoked in the settings device surface.
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
export interface PairingModalConnectionInfo {
|
|
29
|
+
/** The web-app origin the deep link opens (e.g. http://workshop.local:3141). */
|
|
29
30
|
readonly url: string;
|
|
31
|
+
/** The one-time pairing secret (masked unless revealed). */
|
|
30
32
|
readonly token: string;
|
|
31
|
-
|
|
32
|
-
readonly
|
|
33
|
-
|
|
34
|
-
readonly
|
|
33
|
+
/** The per-device token's editable name. */
|
|
34
|
+
readonly tokenName: string;
|
|
35
|
+
/** The `#pair=<token>` deep link the QR encodes — opens the web app signed in. */
|
|
36
|
+
readonly deepLink: string;
|
|
37
|
+
/** The offer set this pairing carries (each declinable in the web app). */
|
|
38
|
+
readonly offers: readonly PairingHandoffOfferKind[];
|
|
39
|
+
/**
|
|
40
|
+
* The honest TLS/capability posture of the web origin the deep link opens
|
|
41
|
+
* (the SDK's describeOriginPosture / the handoff's `posture` field). The one
|
|
42
|
+
* honest LAN line and the labeled capability list both render from here — never
|
|
43
|
+
* from a locally-authored string. Absent when no web origin is known.
|
|
44
|
+
*/
|
|
45
|
+
readonly posture?: OriginPosture | undefined;
|
|
35
46
|
}
|
|
36
47
|
export interface PairingModalControlPlaneSnapshot { readonly activeClientIds: readonly string[]; }
|
|
37
48
|
export interface PairingModalReadModel<T> { getSnapshot(): T; }
|
|
38
49
|
|
|
50
|
+
/** tailscale.get shape — the daemon's honest tailscale detection (quiet when absent). */
|
|
51
|
+
export interface PairingTailscaleStatus {
|
|
52
|
+
readonly available: boolean;
|
|
53
|
+
readonly loggedIn: boolean;
|
|
54
|
+
readonly magicDnsName?: string | undefined;
|
|
55
|
+
readonly httpsUrl?: string | undefined;
|
|
56
|
+
readonly detail: string;
|
|
57
|
+
readonly lastServe?: PairingTailscaleServeReceipt | undefined;
|
|
58
|
+
}
|
|
59
|
+
/** tailscale.serve.run receipt — the one-action serve result rendered verbatim. */
|
|
60
|
+
export interface PairingTailscaleServeReceipt {
|
|
61
|
+
readonly at: number;
|
|
62
|
+
readonly command: string;
|
|
63
|
+
readonly ok: boolean;
|
|
64
|
+
readonly url?: string | undefined;
|
|
65
|
+
readonly detail: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
39
68
|
export interface PairingModalDeps {
|
|
40
69
|
/** Lazy connection-info provider — returns null when the daemon/companion
|
|
41
70
|
* token cannot be resolved (honest degraded state instead of a throw). */
|
|
42
71
|
readonly getConnectionInfo: () => PairingModalConnectionInfo | null;
|
|
43
72
|
readonly controlPlaneReadModel?: PairingModalReadModel<PairingModalControlPlaneSnapshot>;
|
|
44
73
|
readonly copyToClipboard?: (text: string) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Probe tailscale (tailscale.get) once on open. Absent dep, a null result, or
|
|
76
|
+
* `available:false` all keep the tailscale affordance QUIET — never a nag.
|
|
77
|
+
* Present + available ⇒ the surface offers the one-action serve.
|
|
78
|
+
*/
|
|
79
|
+
readonly probeTailscale?: () => Promise<PairingTailscaleStatus | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Run tailscale.serve.run (behind the standard confirm) and return its receipt.
|
|
82
|
+
* Wired only when probeTailscale is; the resulting https MagicDNS URL and the
|
|
83
|
+
* receipt render in place.
|
|
84
|
+
*/
|
|
85
|
+
readonly runTailscaleServe?: () => Promise<PairingTailscaleServeReceipt | null>;
|
|
45
86
|
}
|
|
46
87
|
|
|
47
88
|
// Fixed-width placeholder — deliberately NOT derived from the real secret's
|
|
@@ -50,20 +91,45 @@ const SECRET_MASK = '••••••••••••';
|
|
|
50
91
|
|
|
51
92
|
class PairingModalSurface implements ConfigModalSurface {
|
|
52
93
|
readonly name = 'pairing-modal';
|
|
53
|
-
readonly title = '
|
|
94
|
+
readonly title = 'Device Pairing';
|
|
54
95
|
private info: PairingModalConnectionInfo | null | undefined = undefined;
|
|
55
96
|
private revealed = false;
|
|
56
97
|
private requestRender: () => void = () => {};
|
|
98
|
+
/** tailscale.get result: undefined = not probed yet, null = probed/absent (stays quiet). */
|
|
99
|
+
private tailscale: PairingTailscaleStatus | null | undefined = undefined;
|
|
100
|
+
/** The receipt from a completed serve run, rendered verbatim under the affordance. */
|
|
101
|
+
private serveReceipt: PairingTailscaleServeReceipt | null = null;
|
|
102
|
+
/** True while a serve run is in flight (the action is single-shot). */
|
|
103
|
+
private serving = false;
|
|
57
104
|
|
|
58
105
|
constructor(private readonly deps: PairingModalDeps) {}
|
|
59
106
|
|
|
60
107
|
readonly actions = [
|
|
61
108
|
{ key: 'v', id: 'toggleReveal', label: 'reveal token' },
|
|
62
|
-
{ key: 'c', id: '
|
|
63
|
-
{ key: 'r', id: '
|
|
109
|
+
{ key: 'c', id: 'copyLink', label: 'copy link', enabledFor: () => Boolean(this.deps.copyToClipboard) },
|
|
110
|
+
{ key: 'r', id: 'newToken', label: 'new device token' },
|
|
111
|
+
// The one-action serve affordance — offered ONLY when tailscale is detected
|
|
112
|
+
// and not already serving https; a second 't' press (the standard confirm)
|
|
113
|
+
// runs tailscale.serve.run. Absence keeps this key unadvertised and inert.
|
|
114
|
+
{
|
|
115
|
+
key: 't',
|
|
116
|
+
id: 'tailscaleServe',
|
|
117
|
+
label: 'serve over tailscale (https)',
|
|
118
|
+
confirm: true,
|
|
119
|
+
enabledFor: () => this.tailscale?.available === true && !this.serving,
|
|
120
|
+
},
|
|
64
121
|
];
|
|
65
122
|
|
|
66
|
-
onOpen(requestRender: () => void): void {
|
|
123
|
+
onOpen(requestRender: () => void): void {
|
|
124
|
+
this.requestRender = requestRender;
|
|
125
|
+
// Probe tailscale once on open; the affordance stays quiet until (and unless)
|
|
126
|
+
// this resolves to an available status. Failures degrade to quiet absence.
|
|
127
|
+
if (this.deps.probeTailscale && this.tailscale === undefined) {
|
|
128
|
+
this.deps.probeTailscale()
|
|
129
|
+
.then((status) => { this.tailscale = status ?? null; this.requestRender(); })
|
|
130
|
+
.catch(() => { this.tailscale = null; this.requestRender(); });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
67
133
|
|
|
68
134
|
private ensureInfo(): PairingModalConnectionInfo | null {
|
|
69
135
|
if (this.info === undefined) {
|
|
@@ -75,71 +141,130 @@ class PairingModalSurface implements ConfigModalSurface {
|
|
|
75
141
|
buildView(): ConfigModalView {
|
|
76
142
|
const info = this.ensureInfo();
|
|
77
143
|
if (!info) {
|
|
78
|
-
return { title: '
|
|
144
|
+
return { title: 'Device Pairing', degraded: 'Device pairing is unavailable — the pairing token service could not be resolved for this session.', tabs: [{ id: 'pairing', label: 'Pairing', rows: [] }] };
|
|
79
145
|
}
|
|
80
|
-
const { url, token,
|
|
146
|
+
const { url, token, tokenName, deepLink, offers, posture } = info;
|
|
81
147
|
const displayToken = this.revealed ? token : SECRET_MASK;
|
|
82
|
-
|
|
148
|
+
// The deep link carries the one-time token, so it is masked with the token.
|
|
149
|
+
const displayLink = this.revealed ? deepLink : SECRET_MASK;
|
|
83
150
|
|
|
84
151
|
const rows: ConfigModalRow[] = [];
|
|
85
|
-
rows.push(infoRow('intro', 'Scan with the GoodVibes
|
|
86
|
-
rows.push(infoRow('url', `
|
|
152
|
+
rows.push(infoRow('intro', 'Scan with the GoodVibes web app to pair this device — it opens already signed in.', { dim: true }));
|
|
153
|
+
rows.push(infoRow('url', `Web app ${url}`));
|
|
154
|
+
rows.push(infoRow('name', `Device ${tokenName}`));
|
|
87
155
|
rows.push(infoRow('token', `Token ${displayToken}`, { fg: MODAL_TONES.reasoning }));
|
|
88
|
-
rows.push(infoRow('
|
|
89
|
-
if (displayPassword !== undefined) rows.push(infoRow('password', `Password ${displayPassword}`));
|
|
156
|
+
rows.push(infoRow('link', `Link ${displayLink}`, { dim: true }));
|
|
90
157
|
if (this.deps.controlPlaneReadModel) {
|
|
91
158
|
const connected = this.deps.controlPlaneReadModel.getSnapshot().activeClientIds.length;
|
|
92
|
-
rows.push(infoRow('companions', `
|
|
159
|
+
rows.push(infoRow('companions', `Devices connected: ${connected}`, connected > 0 ? { fg: MODAL_TONES.good } : { dim: true }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The offer set carried by this pairing — each named with its plain-language
|
|
163
|
+
// consequence, each declinable in the web app.
|
|
164
|
+
if (offers.length > 0) {
|
|
165
|
+
rows.push(infoRow('offers-h', 'Offers (each declinable when you pair):', { dim: true }));
|
|
166
|
+
formatPairingOffers(offers).forEach((line, i) => rows.push(infoRow(`offer:${i}`, line)));
|
|
167
|
+
}
|
|
168
|
+
// The labeled browser-capability list — what the paired device actually gets
|
|
169
|
+
// over this origin (and, for a gated one, why not). Rendered from the posture,
|
|
170
|
+
// never hidden behind a dead button.
|
|
171
|
+
const capabilities = formatPostureCapabilities(posture);
|
|
172
|
+
if (capabilities.length > 0) {
|
|
173
|
+
rows.push(infoRow('caps-h', 'This device will get:', { dim: true }));
|
|
174
|
+
capabilities.forEach((line, i) => rows.push(infoRow(`cap:${i}`, line)));
|
|
93
175
|
}
|
|
176
|
+
// The ONE honest LAN line — the SDK posture's own notice (LAN_PLAIN_HTTP_NOTICE),
|
|
177
|
+
// present only for the plain-http-on-LAN posture, never a nag elsewhere.
|
|
178
|
+
const notice = pairingPostureNotice(posture);
|
|
179
|
+
if (notice) rows.push(infoRow('posture', notice, { fg: MODAL_TONES.reasoning }));
|
|
180
|
+
// The tailscale serve affordance — quiet unless tailscale is detected.
|
|
181
|
+
this.appendTailscaleRows(rows);
|
|
94
182
|
|
|
95
|
-
// ASCII QR
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
...(password !== undefined ? { password } : {}),
|
|
100
|
-
version: info.version ?? '0.0.0',
|
|
101
|
-
surface: info.surface ?? 'tui',
|
|
102
|
-
});
|
|
103
|
-
const qr = renderQrToString(generateQrMatrix(payload));
|
|
183
|
+
// ASCII QR of the deep link — a camera scan opens the web app already
|
|
184
|
+
// carrying the one-time token. No raw JSON blob is ever encoded. The
|
|
185
|
+
// multi-line preformatted string is split into one non-selectable row per row.
|
|
186
|
+
const qr = renderQrToString(generateQrMatrix(deepLink));
|
|
104
187
|
qr.split('\n').forEach((line, i) => rows.push(infoRow(`qr:${i}`, line, { fg: MODAL_TONES.qrDark, bg: MODAL_TONES.qrLight })));
|
|
105
188
|
|
|
189
|
+
const hints = [this.revealed ? 'v hide token' : 'v reveal token'];
|
|
190
|
+
if (this.tailscale?.available && !this.serving) hints.push('t serve over tailscale');
|
|
106
191
|
return {
|
|
107
|
-
title: '
|
|
108
|
-
tabs: [{ id: 'pairing', label: 'Pairing', rows, hints
|
|
192
|
+
title: 'Device Pairing',
|
|
193
|
+
tabs: [{ id: 'pairing', label: 'Pairing', rows, hints }],
|
|
109
194
|
};
|
|
110
195
|
}
|
|
111
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Append the tailscale section — quiet when tailscale is undetected or
|
|
199
|
+
* unavailable. When detected, the surface names the one-action serve; once a
|
|
200
|
+
* serve has produced (or tailscale already reports) an https MagicDNS URL, that
|
|
201
|
+
* URL and the serve receipt render verbatim so the encrypted path is visible.
|
|
202
|
+
*/
|
|
203
|
+
private appendTailscaleRows(rows: ConfigModalRow[]): void {
|
|
204
|
+
const ts = this.tailscale;
|
|
205
|
+
if (!ts || !ts.available) return; // absence stays quiet
|
|
206
|
+
const httpsUrl = this.serveReceipt?.url ?? ts.httpsUrl;
|
|
207
|
+
if (httpsUrl) {
|
|
208
|
+
rows.push(infoRow('ts-h', 'Encrypted access (Tailscale):', { dim: true }));
|
|
209
|
+
rows.push(infoRow('ts-url', ` ${httpsUrl}`, { fg: MODAL_TONES.good }));
|
|
210
|
+
} else {
|
|
211
|
+
rows.push(infoRow('ts-h', 'Tailscale detected — press t to serve this app over https (MagicDNS).', { fg: MODAL_TONES.reasoning }));
|
|
212
|
+
}
|
|
213
|
+
const receipt = this.serveReceipt ?? ts.lastServe;
|
|
214
|
+
if (receipt) {
|
|
215
|
+
rows.push(infoRow('ts-receipt', ` ${receipt.ok ? '✓' : '✗'} ${receipt.detail}`, receipt.ok ? { dim: true } : { fg: MODAL_TONES.warn }));
|
|
216
|
+
} else if (this.serving) {
|
|
217
|
+
rows.push(infoRow('ts-serving', ' Serving over tailscale…', { dim: true }));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
112
221
|
onAction(id: string, ctx: ConfigModalActionContext): void {
|
|
113
222
|
const info = this.ensureInfo();
|
|
114
223
|
if (id === 'toggleReveal') { this.revealed = !this.revealed; this.requestRender(); return; }
|
|
115
|
-
if (id === '
|
|
224
|
+
if (id === 'tailscaleServe') { this.runTailscaleServe(ctx); return; }
|
|
225
|
+
if (id === 'copyLink') {
|
|
116
226
|
if (!this.deps.copyToClipboard || !info) { ctx.print('Clipboard not available.'); return; }
|
|
117
|
-
this.deps.copyToClipboard(info.
|
|
118
|
-
ctx.print('
|
|
227
|
+
this.deps.copyToClipboard(info.deepLink);
|
|
228
|
+
ctx.print('Pairing link copied to clipboard.');
|
|
119
229
|
return;
|
|
120
230
|
}
|
|
121
|
-
if (id === '
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Re-pull the lazy connection-info thunk: /qrcode regenerate rewrote
|
|
132
|
-
// the shared token store, so a fresh getConnectionInfo() reflects the
|
|
133
|
-
// rotated token + QR. ensureInfo caches on first build — clear it.
|
|
134
|
-
this.info = undefined;
|
|
135
|
-
this.revealed = false;
|
|
136
|
-
this.ensureInfo();
|
|
137
|
-
this.requestRender();
|
|
138
|
-
ctx.setStatus('Pairing token rotated — new token and QR loaded.');
|
|
139
|
-
})
|
|
140
|
-
.catch(() => { ctx.setStatus('Token rotation failed.'); });
|
|
231
|
+
if (id === 'newToken') {
|
|
232
|
+
// Each open mints its own named token; "new device token" just re-pulls the
|
|
233
|
+
// thunk to mint a fresh one + QR in place. Non-destructive: the prior token
|
|
234
|
+
// stays valid until it is revoked in /settings → security → devices.
|
|
235
|
+
this.info = undefined;
|
|
236
|
+
this.revealed = false;
|
|
237
|
+
this.ensureInfo();
|
|
238
|
+
this.requestRender();
|
|
239
|
+
ctx.setStatus('Minted a fresh device token and QR. Revoke old ones in /settings → security → devices.');
|
|
240
|
+
return;
|
|
141
241
|
}
|
|
142
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Run the one-action tailscale serve (tailscale.serve.run) after the standard
|
|
246
|
+
* confirm has fired. The resulting https MagicDNS URL and receipt render in
|
|
247
|
+
* place; a failed run keeps its honest receipt detail rather than a dead row.
|
|
248
|
+
*/
|
|
249
|
+
private runTailscaleServe(ctx: ConfigModalActionContext): void {
|
|
250
|
+
if (!this.deps.runTailscaleServe || this.serving) return;
|
|
251
|
+
this.serving = true;
|
|
252
|
+
this.requestRender();
|
|
253
|
+
this.deps.runTailscaleServe()
|
|
254
|
+
.then((receipt) => {
|
|
255
|
+
this.serving = false;
|
|
256
|
+
this.serveReceipt = receipt;
|
|
257
|
+
if (receipt?.ok && receipt.url) ctx.setStatus(`Serving over tailscale: ${receipt.url}`);
|
|
258
|
+
else if (receipt) ctx.setStatus(`Tailscale serve did not complete: ${receipt.detail}`);
|
|
259
|
+
this.requestRender();
|
|
260
|
+
})
|
|
261
|
+
.catch((err: unknown) => {
|
|
262
|
+
this.serving = false;
|
|
263
|
+
this.serveReceipt = { at: Date.now(), command: 'tailscale serve', ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
264
|
+
ctx.setStatus('Tailscale serve failed.');
|
|
265
|
+
this.requestRender();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
143
268
|
}
|
|
144
269
|
|
|
145
270
|
export function createPairingModalSurface(deps: PairingModalDeps): ConfigModalSurface {
|
|
@@ -147,13 +272,18 @@ export function createPairingModalSurface(deps: PairingModalDeps): ConfigModalSu
|
|
|
147
272
|
}
|
|
148
273
|
|
|
149
274
|
/**
|
|
150
|
-
* Deterministic golden fixture: a frozen connectionInfo literal (
|
|
151
|
-
*
|
|
152
|
-
*
|
|
275
|
+
* Deterministic golden fixture: a frozen connectionInfo literal (the QR renders
|
|
276
|
+
* purely from its deepLink via generateQrMatrix) and a frozen control-plane
|
|
277
|
+
* snapshot.
|
|
153
278
|
*/
|
|
154
279
|
export function pairingModalGoldenSurface(): ConfigModalSurface {
|
|
155
280
|
const connectionInfo: PairingModalConnectionInfo = {
|
|
156
|
-
url: 'http://
|
|
281
|
+
url: 'http://workshop.local:3141',
|
|
282
|
+
token: 'golden-token-0123456789abcdef',
|
|
283
|
+
tokenName: 'golden device',
|
|
284
|
+
deepLink: 'http://workshop.local:3141/#pair=golden-token-0123456789abcdef&offers=notifications~relay~passkey',
|
|
285
|
+
offers: ['notifications', 'relay', 'passkey'],
|
|
286
|
+
posture: describeOriginPosture('http://workshop.local:3141'),
|
|
157
287
|
};
|
|
158
288
|
return createPairingModalSurface({
|
|
159
289
|
getConnectionInfo: () => connectionInfo,
|
|
@@ -18,12 +18,12 @@ import type {
|
|
|
18
18
|
import { buildAnswerActions, isGenericRecommendation, type PlanningAnswerAction } from '../project-planning-answer-actions.ts';
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
|
-
// Project Planning → 'planning' config-modal surface (
|
|
21
|
+
// Project Planning → 'planning' config-modal surface (group-B port). Shows
|
|
22
22
|
// readiness/questions/decisions/task-graph/handoff — read-only except choosing
|
|
23
23
|
// an answer to the current open question, approving execution, dismissing the
|
|
24
24
|
// plan, or refreshing.
|
|
25
25
|
//
|
|
26
|
-
//
|
|
26
|
+
// — the seams are now real:
|
|
27
27
|
// - A CANNED answer to a real open question dispatches `/plan answer <id> <text>`
|
|
28
28
|
// (records it; the open-question gap clears on the next refine).
|
|
29
29
|
// - The CUSTOM free-form typed answer (and any answer to a synthetic readiness
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
} from '@pellux/goodvibes-sdk/platform/plugins';
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
|
-
// Plugins → config-modal surface (
|
|
16
|
+
// Plugins → config-modal surface (group-B port). Mirrors the retired
|
|
17
17
|
// PluginsPanel's read surface (list/capabilities/getTrustRecord/
|
|
18
18
|
// getQuarantineRecord) plus `verify` (a pure read-only signature check).
|
|
19
19
|
// enable/disable/liftQuarantine are DROPPED from the dep shape on purpose:
|
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
} from '../../input/config-modal-types.ts';
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
|
-
// Policy → config-modal surface (
|
|
21
|
+
// Policy → config-modal surface (group-B port). Governance CONTENT view,
|
|
22
22
|
// not a selectable list — current/candidate bundle state, diff, divergence
|
|
23
23
|
// gate, history, permission audit, lint findings, preflight review, and
|
|
24
24
|
// simulation samples render as non-selectable rows (the host windows/scrolls
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
} from '../../input/config-modal-types.ts';
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
|
-
// Security → config-modal surface (
|
|
15
|
+
// Security → config-modal surface (group-B port). Two tabs: 'Tokens' (the
|
|
16
16
|
// token scope/rotation audit list under the governance summary) and
|
|
17
17
|
// 'Governance' (policy preflight, MCP/plugin quarantine, latest incident, and
|
|
18
18
|
// the MCP attack-path review). Read-model-backed, so refresh() is a no-op —
|
|
@@ -74,6 +74,7 @@ class SecurityModalSurface implements ConfigModalSurface {
|
|
|
74
74
|
readonly actions = [
|
|
75
75
|
{ key: 'f', id: 'preflight', label: 'preflight' },
|
|
76
76
|
{ key: 'i', id: 'jumpToIncident', label: 'jump to incident', enabledFor: () => Boolean(this.deps.readModel.getSnapshot().latestIncident) },
|
|
77
|
+
{ key: 'd', id: 'manageDevices', label: 'manage devices' },
|
|
77
78
|
{ key: 'r', id: 'refresh', label: 'refresh audit' },
|
|
78
79
|
];
|
|
79
80
|
|
|
@@ -170,6 +171,11 @@ class SecurityModalSurface implements ConfigModalSurface {
|
|
|
170
171
|
if (id === 'jumpToIncident' && this.deps.readModel.getSnapshot().latestIncident) {
|
|
171
172
|
void ctx.executeCommand?.('panel', ['open', 'incident']);
|
|
172
173
|
ctx.setStatus('Opened the incident surface (fleet).');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (id === 'manageDevices') {
|
|
177
|
+
ctx.openModal?.('devices-modal');
|
|
178
|
+
ctx.setStatus('Opened paired-device management.');
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
}
|
|
@@ -8,7 +8,7 @@ import type { SkillOrigin, SkillRecord } from '../skills-panel.ts';
|
|
|
8
8
|
import { listInstalledEcosystemEntries, type EcosystemCatalogPathOptions, type ShellPathService } from '@/runtime/index.ts';
|
|
9
9
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
|
-
// Skills → config-modal surface (
|
|
11
|
+
// Skills → config-modal surface (group-B port). Project-local and global
|
|
12
12
|
// skill-pack discovery. Disk scans happen only in refresh() (never in
|
|
13
13
|
// buildView), mirroring the panel's onActivate()-triggered load. Browse-only:
|
|
14
14
|
// the panel never had a command-routable mutation verb (no /skills delete/remove
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
import type { WorkPlanItemStatus } from '../../work-plans/work-plan-store.ts';
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
|
-
// Work Plan → config-modal surface (
|
|
10
|
+
// Work Plan → config-modal surface (group-B port). Lists the active
|
|
11
11
|
// persistent plan's checklist items with a progress summary + status counts.
|
|
12
12
|
// All mutations (add/edit item, cycle/set status, remove, clear-completed)
|
|
13
13
|
// route to the existing `/work-plan` command path (charter: no
|
|
@@ -65,8 +65,8 @@ class WorkPlanModalSurface implements ConfigModalSurface {
|
|
|
65
65
|
private readonly hasRow = (row: ConfigModalRow | null): boolean => row !== null;
|
|
66
66
|
|
|
67
67
|
/** 'i'/'w' jump to the selected item's linked agent/WRFC chain in Fleet
|
|
68
|
-
* (
|
|
69
|
-
* lost when
|
|
68
|
+
* (item 4 — restores the retired WorkPlanPanel's per-item deep-link,
|
|
69
|
+
* lost when migrated this surface to a modal without it). Gated on
|
|
70
70
|
* the link actually being present, same as the retired panel gated the key
|
|
71
71
|
* on `item?.linked?.agentId`/`wrfcId` before ever reaching the jump. */
|
|
72
72
|
private readonly hasAgentLink = (row: ConfigModalRow | null): boolean =>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PanelNotificationFeed — the read-model behind the `panel_only` notification
|
|
3
|
+
* target.
|
|
4
|
+
*
|
|
5
|
+
* The SDK's NotificationRouter (`@/runtime/index.ts` -> platform runtime `ui`
|
|
6
|
+
* barrel) can route a notification's `RoutingDecision.target` to
|
|
7
|
+
* `'panel_only'`, meaning: don't surface this in the conversation or the
|
|
8
|
+
* status bar, but it must still reach a real, visible place. Before this
|
|
9
|
+
* file, nothing in this repo consumed that target outside of tests — a
|
|
10
|
+
* panel-routed notification (including every burst- or batch-collapsed one)
|
|
11
|
+
* had nowhere to go. This feed is that place: a caller holding a routed
|
|
12
|
+
* `Notification` + `RoutingDecision` pair calls `record()`, and
|
|
13
|
+
* `notifications-panel.ts` renders whatever has accumulated.
|
|
14
|
+
*
|
|
15
|
+
* Collapsed groups (reasonCode `burst_collapsed` / `batch_window_collapsed`)
|
|
16
|
+
* are tracked by their `batchKey` and accumulate an honest running count
|
|
17
|
+
* instead of rendering one row per suppressed duplicate.
|
|
18
|
+
*
|
|
19
|
+
* Production wiring — something in the running app actually calling
|
|
20
|
+
* `record()` with live notifications — rides the SDK round that turns on
|
|
21
|
+
* adaptive notification suppression by default; this feed and its panel are
|
|
22
|
+
* the render target that work lands on, built ahead of it so the target is
|
|
23
|
+
* never missing when that switch flips.
|
|
24
|
+
*/
|
|
25
|
+
import type { Notification, RoutingDecision } from '@/runtime/index.ts';
|
|
26
|
+
|
|
27
|
+
export interface PanelFeedEntry {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly domain: string;
|
|
30
|
+
readonly level: Notification['level'];
|
|
31
|
+
readonly title: string;
|
|
32
|
+
readonly body: string | undefined;
|
|
33
|
+
/** Unix ms of the most recent notification folded into this entry. */
|
|
34
|
+
readonly timestamp: number;
|
|
35
|
+
readonly reasonCode: RoutingDecision['reasonCode'];
|
|
36
|
+
/** How many notifications this entry represents. 1 for a standalone item; >1 for a collapsed group. Always the true count — never estimated. */
|
|
37
|
+
readonly collapsedCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MAX_ENTRIES = 200;
|
|
41
|
+
|
|
42
|
+
/** Reason codes whose notifications fold into one running-count entry per batch key, rather than one entry per occurrence. */
|
|
43
|
+
const COLLAPSING_REASON_CODES: ReadonlySet<RoutingDecision['reasonCode']> = new Set([
|
|
44
|
+
'burst_collapsed',
|
|
45
|
+
'batch_window_collapsed',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export class PanelNotificationFeed {
|
|
49
|
+
private readonly entries = new Map<string, PanelFeedEntry>();
|
|
50
|
+
/** Insertion order of `entries` keys (oldest first), for bounded eviction. */
|
|
51
|
+
private order: string[] = [];
|
|
52
|
+
private readonly listeners = new Set<() => void>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Record a routed notification. Only notifications actually targeted at
|
|
56
|
+
* `panel_only` are kept — a caller passing anything else is a mistake (the
|
|
57
|
+
* router sent it elsewhere), so it's dropped rather than shown in the
|
|
58
|
+
* wrong place.
|
|
59
|
+
*/
|
|
60
|
+
record(notification: Notification, decision: RoutingDecision): void {
|
|
61
|
+
if (decision.target !== 'panel_only') return;
|
|
62
|
+
|
|
63
|
+
const collapsing = COLLAPSING_REASON_CODES.has(decision.reasonCode) && Boolean(decision.batchKey);
|
|
64
|
+
const key = collapsing ? `group:${decision.batchKey}` : `single:${notification.id}`;
|
|
65
|
+
const previousCount = collapsing ? (this.entries.get(key)?.collapsedCount ?? 0) : 0;
|
|
66
|
+
|
|
67
|
+
const entry: PanelFeedEntry = {
|
|
68
|
+
key,
|
|
69
|
+
domain: notification.domain,
|
|
70
|
+
level: notification.level,
|
|
71
|
+
title: notification.title,
|
|
72
|
+
body: notification.body,
|
|
73
|
+
timestamp: notification.timestamp,
|
|
74
|
+
reasonCode: decision.reasonCode,
|
|
75
|
+
collapsedCount: previousCount + 1,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (!this.entries.has(key)) {
|
|
79
|
+
this.order.push(key);
|
|
80
|
+
if (this.order.length > MAX_ENTRIES) {
|
|
81
|
+
const evicted = this.order.shift();
|
|
82
|
+
if (evicted !== undefined) this.entries.delete(evicted);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.entries.set(key, entry);
|
|
86
|
+
this.emitChange();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every entry, most recently updated first. */
|
|
90
|
+
list(): readonly PanelFeedEntry[] {
|
|
91
|
+
const items: PanelFeedEntry[] = [];
|
|
92
|
+
for (const key of this.order) {
|
|
93
|
+
const entry = this.entries.get(key);
|
|
94
|
+
if (entry) items.push(entry);
|
|
95
|
+
}
|
|
96
|
+
return items.reverse();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
clear(): void {
|
|
100
|
+
this.entries.clear();
|
|
101
|
+
this.order = [];
|
|
102
|
+
this.emitChange();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Subscribe to feed changes (e.g. to mark a panel dirty). Returns an unsubscribe function. */
|
|
106
|
+
subscribe(listener: () => void): () => void {
|
|
107
|
+
this.listeners.add(listener);
|
|
108
|
+
return () => this.listeners.delete(listener);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private emitChange(): void {
|
|
112
|
+
for (const listener of this.listeners) listener();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let sharedFeed: PanelNotificationFeed | null = null;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The process-wide `panel_only` feed. Lazily created so the running app has
|
|
120
|
+
* exactly one feed that every future caller and the NotificationsPanel agree
|
|
121
|
+
* on, while tests construct their own isolated `PanelNotificationFeed`
|
|
122
|
+
* instance instead of reaching for this one.
|
|
123
|
+
*/
|
|
124
|
+
export function getSharedNotificationFeed(): PanelNotificationFeed {
|
|
125
|
+
if (!sharedFeed) sharedFeed = new PanelNotificationFeed();
|
|
126
|
+
return sharedFeed;
|
|
127
|
+
}
|