From 59a4cf175e1a611feb172b9bfa60d300787cc281 Mon Sep 17 00:00:00 2001 From: Pawel Mikolajczuk Date: Sat, 12 Sep 2026 21:23:18 +1000 Subject: [PATCH] feat: add the Control center and its decision registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bots that need the owner's call currently have nowhere to put the question: it goes into a terminal nobody is watching, and the answer — when it comes — lives in one bot's memory file, where the next bot cannot find it and the owner cannot remember giving it. The decision registry makes that exchange durable. A bot raises a decision, the owner rules on it in the Control center, and the ruling is delivered to every bot that needs it and kept as a searchable record. A ruling a bot heard at its own terminal can be filed back with `record_decision`, marked as relayed until the owner confirms the words are theirs. Daemon: the registry's schema, state machine, dedupe and publish step; MCP tools for bots; decision requests and `decision_update` pushes on the control plane; a tag taxonomy with rename, delete and usage counts. Desktop: the Control center view — a waiting list, a reading pane, a composer, a publish tray, a settled registry and a tags panel — plus its sidebar row and badge. `approve` becomes a real capability distinct from `control`: a credential that can run the fleet is not thereby the owner, and a ruling published from one would be indistinguishable from one they typed. --- apps/desktop/package.json | 4 +- apps/desktop/pnpm-lock.yaml | 42 + apps/desktop/src-tauri/Cargo.lock | 521 ++ apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 3 +- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/App.tsx | 13 +- apps/desktop/src/analytics.tsx | 23 + apps/desktop/src/app/selection.ts | 7 +- apps/desktop/src/app/useDaemonActions.ts | 29 +- apps/desktop/src/app/useDaemonPushes.ts | 25 +- apps/desktop/src/app/useDaemonState.ts | 10 + apps/desktop/src/app/useDesktopShell.ts | 3 +- apps/desktop/src/app/useDockBadge.test.ts | 22 +- apps/desktop/src/app/useDockBadge.ts | 16 +- apps/desktop/src/app/usePaletteActions.ts | 8 + apps/desktop/src/app/usePendingDecisions.ts | 49 + apps/desktop/src/components/MainPane.tsx | 17 + .../src/components/ProjectView.test.tsx | 20 +- apps/desktop/src/components/ProjectView.tsx | 27 +- apps/desktop/src/components/Sidebar.test.tsx | 23 + apps/desktop/src/components/Sidebar.tsx | 9 + .../components/control/Composer.stories.tsx | 51 + .../src/components/control/Composer.test.tsx | 138 + .../src/components/control/Composer.tsx | 124 + .../src/components/control/ControlBody.tsx | 88 + .../control/ControlCenterView.test.tsx | 395 ++ .../components/control/ControlCenterView.tsx | 68 + .../src/components/control/ControlHeader.tsx | 68 + .../src/components/control/ControlTop.tsx | 62 + .../control/DecisionReader.stories.tsx | 231 + .../control/DecisionReader.test.tsx | 221 + .../src/components/control/DecisionReader.tsx | 216 + .../src/components/control/DecisionRow.tsx | 103 + .../src/components/control/DecisionThread.tsx | 58 + .../control/DeleteDecisionDialog.tsx | 23 + .../src/components/control/DraftCard.tsx | 74 + .../src/components/control/HeldCard.tsx | 39 + .../src/components/control/Markdown.test.tsx | 44 + .../src/components/control/Markdown.tsx | 47 + .../src/components/control/NotifyChips.tsx | 48 + .../components/control/OptionList.test.tsx | 55 + .../src/components/control/OptionList.tsx | 99 + .../control/PublishTray.stories.tsx | 91 + .../components/control/PublishTray.test.tsx | 102 + .../src/components/control/PublishTray.tsx | 103 + .../src/components/control/ReadingPane.tsx | 81 + .../components/control/Registry.stories.tsx | 142 + .../src/components/control/Registry.test.tsx | 168 + .../src/components/control/Registry.tsx | 242 + .../src/components/control/RulingCard.tsx | 90 + .../components/control/TagsPanel.stories.tsx | 59 + .../src/components/control/TagsPanel.test.tsx | 131 + .../src/components/control/TagsPanel.tsx | 277 + .../control/WaitingList.stories.tsx | 108 + .../components/control/WaitingList.test.tsx | 125 + .../src/components/control/WaitingList.tsx | 165 + .../src/components/control/controlSession.ts | 56 + .../src/components/control/decisions.test.ts | 229 + .../src/components/control/decisions.ts | 322 ++ .../components/control/publishPlan.test.ts | 86 + .../src/components/control/publishPlan.ts | 78 + .../src/components/control/useBotLookups.ts | 42 + .../components/control/useComposer.test.ts | 65 + .../src/components/control/useComposer.ts | 87 + .../components/control/useControlActions.ts | 125 + .../components/control/useControlCenter.ts | 166 + .../components/control/useControlKeys.test.ts | 198 + .../src/components/control/useControlKeys.ts | 190 + .../src/components/control/useControlState.ts | 286 ++ .../control/useDecisionDetail.test.ts | 113 + .../components/control/useDecisionDetail.ts | 44 + .../control/useDecisionMutations.ts | 222 + .../components/control/useDecisions.test.ts | 301 ++ .../src/components/control/useDecisions.ts | 269 + .../components/control/useNotifySets.test.ts | 57 + .../src/components/control/useNotifySets.ts | 45 + apps/desktop/src/components/control/useNow.ts | 25 + .../control/useReaderScroll.test.tsx | 53 + .../src/components/control/useReaderScroll.ts | 27 + .../src/components/control/useTagAdmin.ts | 69 + .../src/components/devices/NewDeviceForm.tsx | 3 +- .../settings/NotificationSettings.tsx | 30 +- .../sidebar/ControlCenterRow.stories.tsx | 32 + .../components/sidebar/ControlCenterRow.tsx | 48 + apps/desktop/src/notify.test.ts | 61 + apps/desktop/src/notify.ts | 32 + apps/desktop/src/prefs.ts | 19 +- apps/desktop/src/protocol/client.ts | 66 +- apps/desktop/src/protocol/decisionRequests.ts | 100 + apps/desktop/src/protocol/decisions.ts | 118 + apps/desktop/src/protocol/entities.ts | 18 +- apps/desktop/src/protocol/messages.ts | 29 +- apps/desktop/src/protocol/push.test.ts | 48 + apps/desktop/src/protocol/push.ts | 87 + apps/desktop/src/protocol/requests.ts | 2 + apps/desktop/src/protocol/wire.ts | 10 + apps/desktop/src/styles.css | 6 + apps/desktop/src/styles/base.css | 4 + apps/desktop/src/styles/control-composer.css | 346 ++ apps/desktop/src/styles/control-list.css | 310 ++ apps/desktop/src/styles/control-reader.css | 320 ++ apps/desktop/src/styles/control-registry.css | 164 + apps/desktop/src/styles/control-tags.css | 242 + apps/desktop/src/styles/control.css | 304 ++ apps/desktop/src/test/decisionFixtures.ts | 55 + apps/desktop/src/test/fakeDaemon.ts | 25 +- apps/desktop/src/test/sidebar.tsx | 2 + .../__screenshots__/composer--blank.png | Bin 0 -> 13359 bytes .../__screenshots__/composer--hold-open.png | Bin 0 -> 21094 bytes .../__screenshots__/composer--picked.png | Bin 0 -> 16935 bytes .../control-center-row--quiet.png | Bin 0 -> 6068 bytes .../control-center-row--selected.png | Bin 0 -> 6202 bytes .../control-center-row--urgent.png | Bin 0 -> 6192 bytes .../decision-reader--draft.png | Bin 0 -> 77518 bytes .../__screenshots__/decision-reader--held.png | Bin 0 -> 64990 bytes .../decision-reader--long-options.png | Bin 0 -> 78173 bytes .../decision-reader--no-options.png | Bin 0 -> 42720 bytes .../__screenshots__/decision-reader--open.png | Bin 0 -> 82768 bytes .../decision-reader--relayed.png | Bin 0 -> 75441 bytes ...sion-reader--settled-question-expanded.png | Bin 0 -> 71383 bytes .../decision-reader--settled.png | Bin 0 -> 33912 bytes .../decision-reader--withdrawn.png | Bin 0 -> 72096 bytes .../new-device-form--default.png | Bin 13935 -> 14755 bytes .../publish-tray--many-drafts.png | Bin 0 -> 52869 bytes .../publish-tray--two-drafts.png | Bin 0 -> 41811 bytes .../__screenshots__/registry--filtered.png | Bin 0 -> 50505 bytes .../__screenshots__/registry--two-months.png | Bin 0 -> 69488 bytes .../__screenshots__/tags-panel--list.png | Bin 0 -> 45454 bytes .../__screenshots__/tags-panel--read-only.png | Bin 0 -> 45454 bytes .../waiting-list--empty-nothing-settled.png | Bin 0 -> 13530 bytes .../__screenshots__/waiting-list--empty.png | Bin 0 -> 13599 bytes .../__screenshots__/waiting-list--typical.png | Bin 0 -> 42135 bytes crates/bus/src/envelope.rs | 9 +- crates/bus/src/envelope/decisions.rs | 258 + crates/bus/src/schema.rs | 395 +- crates/bus/src/schema/base.rs | 142 + crates/bus/src/schema/decisions.rs | 132 + crates/bus/src/schema/history.rs | 243 + crates/bus/src/types.rs | 4 + crates/bus/src/types/decision_enums.rs | 135 + crates/bus/src/types/decisions.rs | 195 + crates/bus/src/types/entities.rs | 62 +- crates/gravityd/src/actor.rs | 49 + crates/gravityd/src/app.rs | 13 + crates/gravityd/src/botmgmt.rs | 23 +- crates/gravityd/src/botmgmt/archive.rs | 19 +- crates/gravityd/src/botmgmt/create.rs | 27 +- crates/gravityd/src/db.rs | 25 +- crates/gravityd/src/db/bots.rs | 33 + crates/gravityd/src/db/conversations.rs | 12 +- crates/gravityd/src/db/decision_threads.rs | 149 + crates/gravityd/src/db/decisions.rs | 317 ++ crates/gravityd/src/db/decisions_edit.rs | 97 + crates/gravityd/src/db/decisions_list.rs | 322 ++ crates/gravityd/src/db/decisions_tests.rs | 294 ++ crates/gravityd/src/db/projects.rs | 42 +- crates/gravityd/src/db/revisions.rs | 17 +- crates/gravityd/src/db/runs.rs | 1 + crates/gravityd/src/db/tags.rs | 298 ++ crates/gravityd/src/db/tags_tests.rs | 297 ++ crates/gravityd/src/db/tests.rs | 34 +- crates/gravityd/src/decisions.rs | 85 + crates/gravityd/src/decisions/authority.rs | 94 + crates/gravityd/src/decisions/owner.rs | 320 ++ crates/gravityd/src/decisions/publish.rs | 150 + crates/gravityd/src/decisions/record.rs | 139 + crates/gravityd/src/decisions/service.rs | 280 ++ crates/gravityd/src/decisions/validate.rs | 214 + crates/gravityd/src/decisions/view.rs | 143 + crates/gravityd/src/decisions/watch.rs | 91 + crates/gravityd/src/delivery.rs | 17 +- crates/gravityd/src/events.rs | 48 +- crates/gravityd/src/lib.rs | 2 + crates/gravityd/src/mcp.rs | 17 + crates/gravityd/src/mcp/decisions.rs | 253 + crates/gravityd/src/mcp/routines.rs | 20 +- crates/gravityd/src/mcp/schema.rs | 40 +- crates/gravityd/src/mcp/schema_decisions.rs | 138 + crates/gravityd/src/mcp/selfmgmt.rs | 25 +- crates/gravityd/src/mcp/tags.rs | 93 + crates/gravityd/src/mcp/tasks.rs | 9 +- crates/gravityd/src/mcp/tools.rs | 56 +- crates/gravityd/src/messaging.rs | 64 +- crates/gravityd/src/paths/prompt.rs | 55 +- crates/gravityd/src/projectmgmt.rs | 10 +- crates/gravityd/src/scheduler.rs | 8 +- crates/gravityd/src/server.rs | 1 + crates/gravityd/src/supervisor/lifecycle.rs | 10 +- crates/gravityd/src/ws.rs | 159 +- crates/gravityd/src/ws/decisions.rs | 197 + crates/gravityd/src/ws/decisions_publish.rs | 178 + crates/gravityd/src/ws/dispatch.rs | 169 + crates/gravityd/src/ws/messaging.rs | 6 +- crates/gravityd/src/ws/views.rs | 47 + crates/gravityd/tests/bus.rs | 1 + crates/gravityd/tests/common/tasks.rs | 55 + crates/gravityd/tests/control_center.rs | 390 ++ crates/gravityd/tests/control_center_tags.rs | 82 + crates/gravityd/tests/decision_migration.rs | 106 + crates/gravityd/tests/decision_records.rs | 84 + crates/gravityd/tests/decision_tags.rs | 137 + crates/gravityd/tests/decisions.rs | 337 ++ docs/architecture.md | 26 +- docs/protocol.md | 153 +- third-party/DESKTOP_NOTICES.txt | 4475 ++++++++++++++++- third-party/inventory.json | 2065 +++++++- ...ada3edaf50dbc082c9a125058f25def75e625a.txt | 201 + ...a98a84ac3f77aa01837efa8f4ddf70fa8f5b62.txt | 21 + third-party/overrides/index.json | 12 + 210 files changed, 24474 insertions(+), 1098 deletions(-) create mode 100644 apps/desktop/src/app/usePendingDecisions.ts create mode 100644 apps/desktop/src/components/control/Composer.stories.tsx create mode 100644 apps/desktop/src/components/control/Composer.test.tsx create mode 100644 apps/desktop/src/components/control/Composer.tsx create mode 100644 apps/desktop/src/components/control/ControlBody.tsx create mode 100644 apps/desktop/src/components/control/ControlCenterView.test.tsx create mode 100644 apps/desktop/src/components/control/ControlCenterView.tsx create mode 100644 apps/desktop/src/components/control/ControlHeader.tsx create mode 100644 apps/desktop/src/components/control/ControlTop.tsx create mode 100644 apps/desktop/src/components/control/DecisionReader.stories.tsx create mode 100644 apps/desktop/src/components/control/DecisionReader.test.tsx create mode 100644 apps/desktop/src/components/control/DecisionReader.tsx create mode 100644 apps/desktop/src/components/control/DecisionRow.tsx create mode 100644 apps/desktop/src/components/control/DecisionThread.tsx create mode 100644 apps/desktop/src/components/control/DeleteDecisionDialog.tsx create mode 100644 apps/desktop/src/components/control/DraftCard.tsx create mode 100644 apps/desktop/src/components/control/HeldCard.tsx create mode 100644 apps/desktop/src/components/control/Markdown.test.tsx create mode 100644 apps/desktop/src/components/control/Markdown.tsx create mode 100644 apps/desktop/src/components/control/NotifyChips.tsx create mode 100644 apps/desktop/src/components/control/OptionList.test.tsx create mode 100644 apps/desktop/src/components/control/OptionList.tsx create mode 100644 apps/desktop/src/components/control/PublishTray.stories.tsx create mode 100644 apps/desktop/src/components/control/PublishTray.test.tsx create mode 100644 apps/desktop/src/components/control/PublishTray.tsx create mode 100644 apps/desktop/src/components/control/ReadingPane.tsx create mode 100644 apps/desktop/src/components/control/Registry.stories.tsx create mode 100644 apps/desktop/src/components/control/Registry.test.tsx create mode 100644 apps/desktop/src/components/control/Registry.tsx create mode 100644 apps/desktop/src/components/control/RulingCard.tsx create mode 100644 apps/desktop/src/components/control/TagsPanel.stories.tsx create mode 100644 apps/desktop/src/components/control/TagsPanel.test.tsx create mode 100644 apps/desktop/src/components/control/TagsPanel.tsx create mode 100644 apps/desktop/src/components/control/WaitingList.stories.tsx create mode 100644 apps/desktop/src/components/control/WaitingList.test.tsx create mode 100644 apps/desktop/src/components/control/WaitingList.tsx create mode 100644 apps/desktop/src/components/control/controlSession.ts create mode 100644 apps/desktop/src/components/control/decisions.test.ts create mode 100644 apps/desktop/src/components/control/decisions.ts create mode 100644 apps/desktop/src/components/control/publishPlan.test.ts create mode 100644 apps/desktop/src/components/control/publishPlan.ts create mode 100644 apps/desktop/src/components/control/useBotLookups.ts create mode 100644 apps/desktop/src/components/control/useComposer.test.ts create mode 100644 apps/desktop/src/components/control/useComposer.ts create mode 100644 apps/desktop/src/components/control/useControlActions.ts create mode 100644 apps/desktop/src/components/control/useControlCenter.ts create mode 100644 apps/desktop/src/components/control/useControlKeys.test.ts create mode 100644 apps/desktop/src/components/control/useControlKeys.ts create mode 100644 apps/desktop/src/components/control/useControlState.ts create mode 100644 apps/desktop/src/components/control/useDecisionDetail.test.ts create mode 100644 apps/desktop/src/components/control/useDecisionDetail.ts create mode 100644 apps/desktop/src/components/control/useDecisionMutations.ts create mode 100644 apps/desktop/src/components/control/useDecisions.test.ts create mode 100644 apps/desktop/src/components/control/useDecisions.ts create mode 100644 apps/desktop/src/components/control/useNotifySets.test.ts create mode 100644 apps/desktop/src/components/control/useNotifySets.ts create mode 100644 apps/desktop/src/components/control/useNow.ts create mode 100644 apps/desktop/src/components/control/useReaderScroll.test.tsx create mode 100644 apps/desktop/src/components/control/useReaderScroll.ts create mode 100644 apps/desktop/src/components/control/useTagAdmin.ts create mode 100644 apps/desktop/src/components/sidebar/ControlCenterRow.stories.tsx create mode 100644 apps/desktop/src/components/sidebar/ControlCenterRow.tsx create mode 100644 apps/desktop/src/notify.test.ts create mode 100644 apps/desktop/src/notify.ts create mode 100644 apps/desktop/src/protocol/decisionRequests.ts create mode 100644 apps/desktop/src/protocol/decisions.ts create mode 100644 apps/desktop/src/protocol/push.test.ts create mode 100644 apps/desktop/src/protocol/push.ts create mode 100644 apps/desktop/src/styles/control-composer.css create mode 100644 apps/desktop/src/styles/control-list.css create mode 100644 apps/desktop/src/styles/control-reader.css create mode 100644 apps/desktop/src/styles/control-registry.css create mode 100644 apps/desktop/src/styles/control-tags.css create mode 100644 apps/desktop/src/styles/control.css create mode 100644 apps/desktop/src/test/decisionFixtures.ts create mode 100644 apps/desktop/tests/visual/__screenshots__/composer--blank.png create mode 100644 apps/desktop/tests/visual/__screenshots__/composer--hold-open.png create mode 100644 apps/desktop/tests/visual/__screenshots__/composer--picked.png create mode 100644 apps/desktop/tests/visual/__screenshots__/control-center-row--quiet.png create mode 100644 apps/desktop/tests/visual/__screenshots__/control-center-row--selected.png create mode 100644 apps/desktop/tests/visual/__screenshots__/control-center-row--urgent.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--draft.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--held.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--long-options.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--no-options.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--open.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--relayed.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--settled-question-expanded.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--settled.png create mode 100644 apps/desktop/tests/visual/__screenshots__/decision-reader--withdrawn.png create mode 100644 apps/desktop/tests/visual/__screenshots__/publish-tray--many-drafts.png create mode 100644 apps/desktop/tests/visual/__screenshots__/publish-tray--two-drafts.png create mode 100644 apps/desktop/tests/visual/__screenshots__/registry--filtered.png create mode 100644 apps/desktop/tests/visual/__screenshots__/registry--two-months.png create mode 100644 apps/desktop/tests/visual/__screenshots__/tags-panel--list.png create mode 100644 apps/desktop/tests/visual/__screenshots__/tags-panel--read-only.png create mode 100644 apps/desktop/tests/visual/__screenshots__/waiting-list--empty-nothing-settled.png create mode 100644 apps/desktop/tests/visual/__screenshots__/waiting-list--empty.png create mode 100644 apps/desktop/tests/visual/__screenshots__/waiting-list--typical.png create mode 100644 crates/bus/src/envelope/decisions.rs create mode 100644 crates/bus/src/schema/base.rs create mode 100644 crates/bus/src/schema/decisions.rs create mode 100644 crates/bus/src/schema/history.rs create mode 100644 crates/bus/src/types/decision_enums.rs create mode 100644 crates/bus/src/types/decisions.rs create mode 100644 crates/gravityd/src/actor.rs create mode 100644 crates/gravityd/src/db/decision_threads.rs create mode 100644 crates/gravityd/src/db/decisions.rs create mode 100644 crates/gravityd/src/db/decisions_edit.rs create mode 100644 crates/gravityd/src/db/decisions_list.rs create mode 100644 crates/gravityd/src/db/decisions_tests.rs create mode 100644 crates/gravityd/src/db/tags.rs create mode 100644 crates/gravityd/src/db/tags_tests.rs create mode 100644 crates/gravityd/src/decisions.rs create mode 100644 crates/gravityd/src/decisions/authority.rs create mode 100644 crates/gravityd/src/decisions/owner.rs create mode 100644 crates/gravityd/src/decisions/publish.rs create mode 100644 crates/gravityd/src/decisions/record.rs create mode 100644 crates/gravityd/src/decisions/service.rs create mode 100644 crates/gravityd/src/decisions/validate.rs create mode 100644 crates/gravityd/src/decisions/view.rs create mode 100644 crates/gravityd/src/decisions/watch.rs create mode 100644 crates/gravityd/src/mcp/decisions.rs create mode 100644 crates/gravityd/src/mcp/schema_decisions.rs create mode 100644 crates/gravityd/src/mcp/tags.rs create mode 100644 crates/gravityd/src/ws/decisions.rs create mode 100644 crates/gravityd/src/ws/decisions_publish.rs create mode 100644 crates/gravityd/src/ws/dispatch.rs create mode 100644 crates/gravityd/src/ws/views.rs create mode 100644 crates/gravityd/tests/control_center.rs create mode 100644 crates/gravityd/tests/control_center_tags.rs create mode 100644 crates/gravityd/tests/decision_migration.rs create mode 100644 crates/gravityd/tests/decision_records.rs create mode 100644 crates/gravityd/tests/decision_tags.rs create mode 100644 crates/gravityd/tests/decisions.rs create mode 100644 third-party/overrides/8dada3edaf50dbc082c9a125058f25def75e625a.txt create mode 100644 third-party/overrides/a6a98a84ac3f77aa01837efa8f4ddf70fa8f5b62.txt diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 031f010..625d240 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,6 +27,7 @@ "dependencies": { "@posthog/react": "^1.10.5", "@tauri-apps/api": "^2.4.0", + "@tauri-apps/plugin-notification": "^2.4.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", @@ -34,7 +35,8 @@ "lucide-react": "^1.37.0", "posthog-js": "^1.422.5", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0" }, "devDependencies": { "@ladle/react": "^5.1.1", diff --git a/apps/desktop/pnpm-lock.yaml b/apps/desktop/pnpm-lock.yaml index abc76cb..6b8c2a6 100644 --- a/apps/desktop/pnpm-lock.yaml +++ b/apps/desktop/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@tauri-apps/api': specifier: ^2.4.0 version: 2.11.1 + '@tauri-apps/plugin-notification': + specifier: ^2.4.0 + version: 2.4.0 '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -38,6 +41,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.31)(react@18.3.1) devDependencies: '@ladle/react': specifier: ^5.1.1 @@ -1403,6 +1409,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-notification@2.4.0': + resolution: {integrity: sha512-xlJXMcUoKOjNupzDue5wrEsa1wytf+l/2gCAPhafHyP683Y3N7J/8clUWLZ3vpnwkpT2C1zcLMMQFjjecIG2xg==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2280,6 +2289,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -2869,6 +2881,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4344,6 +4362,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/plugin-notification@2.4.0': + dependencies: + '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -5252,6 +5274,8 @@ snapshots: html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} http-assert@1.5.0: @@ -6190,6 +6214,24 @@ snapshots: react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 18.3.31 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.17.0: {} react@18.3.1: diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index ca9e1da..8949330 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -56,6 +56,137 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "atk" version = "0.18.2" @@ -151,6 +282,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -340,6 +484,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -752,6 +905,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -779,6 +959,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -911,6 +1111,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1227,6 +1440,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-global-shortcut", + "tauri-plugin-notification", "tauri-plugin-updater", "toml 0.8.2", "ureq", @@ -1308,6 +1522,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1888,6 +2108,20 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2008,6 +2242,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2271,6 +2519,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2310,6 +2568,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2398,6 +2662,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2443,6 +2718,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2473,6 +2762,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2571,6 +2869,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3110,6 +3437,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3538,6 +3875,25 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "time", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -3671,6 +4027,17 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3997,9 +4364,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4049,6 +4428,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5033,6 +5423,96 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5116,3 +5596,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 82297d1..985a99c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" semver = "1" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index d640086..e035309 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -4,7 +4,8 @@ "description": "Default capability for the main window", "permissions": [ "core:default", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "notification:default" ], "windows": ["main"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1c933d8..5956047 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -175,6 +175,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .plugin(tauri_plugin_notification::init()) .manage(shortcut::ToggleShortcut::default()) .invoke_handler(tauri::generate_handler![ read_client_token, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c8919a3..33fc88a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -6,6 +6,7 @@ import type { FirstRunSetup } from "./app/useFirstRunSetup"; import { useDaemonState } from "./app/useDaemonState"; import type { DaemonState } from "./app/useDaemonState"; import { useDesktopShell } from "./app/useDesktopShell"; +import { usePendingDecisions } from "./app/usePendingDecisions"; import { useOverlays } from "./app/useOverlays"; import type { OverlaysApi } from "./app/useOverlays"; import { usePaletteActions } from "./app/usePaletteActions"; @@ -120,11 +121,17 @@ export default function App(): ReactElement { [select], ); - const paletteActions = usePaletteActions({ bots, select, openSettings: overlays.openSettings }); + const pending = usePendingDecisions(client, daemon.connected); + + const paletteActions = usePaletteActions({ + bots, + select, + openSettings: overlays.openSettings, + }); const failedByBot = useMemo(() => countByBot(daemon.failedDeliveries), [daemon.failedDeliveries]); - useDesktopShell(daemon.unreadBots, addToast); + useDesktopShell(daemon.unreadBots, pending.total, addToast); const setup = useFirstRunSetup(client, daemon.changeEndpoint); @@ -140,6 +147,7 @@ export default function App(): ReactElement { failedByBot={failedByBot} nextRun={daemon.nextRun} activityByBot={daemon.activityByBot} + pendingDecisions={pending} selection={daemon.selection} canControl={canControl} onSelect={select} @@ -157,6 +165,7 @@ export default function App(): ReactElement { addToast={addToast} onCreateProject={actions.createProjectWithBot} onRenameProject={actions.renameProject} + onSetProjectLead={actions.setProjectLead} onDeleteProject={actions.deleteProject} /> diff --git a/apps/desktop/src/analytics.tsx b/apps/desktop/src/analytics.tsx index e05ae17..1f61cc5 100644 --- a/apps/desktop/src/analytics.tsx +++ b/apps/desktop/src/analytics.tsx @@ -12,6 +12,16 @@ interface AnalyticsEvents { readonly runtime: "browser" | "tauri"; }; readonly bot_created: Record; + readonly control_center_opened: Record; + readonly decision_answered: { + readonly picked_option: boolean; + }; + readonly decision_deleted: Record; + readonly decision_held: Record; + readonly decision_published: { + readonly count: number; + readonly notified: number; + }; readonly bot_deleted: Record; readonly daemon_connected: { readonly can_control: boolean; @@ -22,6 +32,9 @@ interface AnalyticsEvents { }; readonly device_revoked: Record; readonly project_created: Record; + readonly project_lead_set: { + readonly cleared: boolean; + }; readonly project_deleted: Record; readonly project_renamed: Record; readonly routine_created: { @@ -48,6 +61,13 @@ export type ErrorOperation = | "bot_create" | "bot_delete" | "daemon_restart" + | "decision_answer" + | "decision_comment" + | "decision_delete" + | "decision_hold" + | "decision_list" + | "decision_publish" + | "decision_update" | "daemon_state_load" | "daemon_update" | "device_create" @@ -55,12 +75,15 @@ export type ErrorOperation = | "device_revoke" | "project_create" | "project_delete" + | "project_lead_set" | "project_rename" | "routine_create" | "routine_list" | "routine_run" | "routine_toggle" | "setup_install" + | "tag_list" + | "tag_update" | "terminal_file_drop" | "window_shortcut"; diff --git a/apps/desktop/src/app/selection.ts b/apps/desktop/src/app/selection.ts index 3eeb7fa..3acfcee 100644 --- a/apps/desktop/src/app/selection.ts +++ b/apps/desktop/src/app/selection.ts @@ -2,4 +2,9 @@ export type Selection = | { readonly kind: "none" } | { readonly kind: "bot"; readonly botId: string } - | { readonly kind: "project"; readonly projectId: string }; + | { readonly kind: "project"; readonly projectId: string } + /** + * The Control center. `decisionId` opens straight onto one record, which is + * what a notification or a palette entry hands over. + */ + | { readonly kind: "control"; readonly decisionId?: string }; diff --git a/apps/desktop/src/app/useDaemonActions.ts b/apps/desktop/src/app/useDaemonActions.ts index 495ea4c..30edf70 100644 --- a/apps/desktop/src/app/useDaemonActions.ts +++ b/apps/desktop/src/app/useDaemonActions.ts @@ -10,6 +10,7 @@ export interface DaemonActions { /** Resolves to the new project's id, or undefined when creation failed. */ readonly createProject: (name: string) => Promise; readonly renameProject: (projectId: string, name: string) => Promise; + readonly setProjectLead: (projectId: string, botId: string | null) => Promise; readonly deleteProject: (projectId: string) => Promise; readonly createBot: (projectId: string) => Promise; /** Creates a project and its first bot, so a fresh install lands on a bot. */ @@ -63,6 +64,23 @@ export function useDaemonActions(deps: ActionDeps): DaemonActions { [addToast, client, refreshAll], ); + const setProjectLead = useCallback( + async (projectId: string, botId: string | null): Promise => { + try { + await client.request( + { type: "set_project_lead", project_id: projectId, bot_id: botId }, + "project", + ); + capture("project_lead_set", { cleared: botId === null }); + await refreshAll(); + } catch (error) { + captureException(error, "project_lead_set"); + addToast("error", "Failed to set the lead bot", errText(error)); + } + }, + [addToast, client, refreshAll], + ); + /** * Deleting a project archives every bot inside it, so anything the main pane * was showing is gone by the time this returns; the selection is cleared @@ -127,11 +145,20 @@ export function useDaemonActions(deps: ActionDeps): DaemonActions { () => ({ createProject, renameProject, + setProjectLead, deleteProject, createBot, createProjectWithBot, deleteBot, }), - [createBot, createProject, createProjectWithBot, deleteBot, deleteProject, renameProject], + [ + createBot, + createProject, + createProjectWithBot, + deleteBot, + deleteProject, + renameProject, + setProjectLead, + ], ); } diff --git a/apps/desktop/src/app/useDaemonPushes.ts b/apps/desktop/src/app/useDaemonPushes.ts index 5d77d90..a3eef17 100644 --- a/apps/desktop/src/app/useDaemonPushes.ts +++ b/apps/desktop/src/app/useDaemonPushes.ts @@ -1,5 +1,6 @@ import { useEffect } from "react"; import type { MutableRefObject } from "react"; +import { notifyNatively } from "../notify"; import type { DaemonApi } from "../protocol/api"; import type { Conversation } from "../protocol/entities"; import type { Selection } from "./selection"; @@ -19,6 +20,10 @@ export interface PushDeps { readonly onSelectBot: (botId: string) => void; /** Refetches the sidebar preview lines; a finished turn changes them. */ readonly refreshActivity: () => void; + /** Opens the Control center, optionally straight onto one decision. */ + readonly onOpenDecision: (decisionId: string) => void; + /** Whether an urgent or nearly-due decision may interrupt with a native notice. */ + readonly decisionNotifications: boolean; } /** True when new activity on `botId` happened out of the user's sight. */ @@ -98,7 +103,25 @@ function subscribe(ref: MutableRefObject): Array<() => void> { }); }), client.on("notify", (push) => { - ref.current.addToast(push.level, push.title, push.body); + const deps = ref.current; + const decisionId = push.decision_id; + if (decisionId === undefined) { + deps.addToast(push.level, push.title, push.body); + return; + } + // A decision notice always offers the way to act on it: the failure this + // replaces was a question the owner could not find. + deps.addToast(push.level, push.title, push.body, { + action: { + label: "Open", + run: () => { + deps.onOpenDecision(decisionId); + }, + }, + }); + if (deps.decisionNotifications) { + void notifyNatively(push.title, push.body); + } }), ]; } diff --git a/apps/desktop/src/app/useDaemonState.ts b/apps/desktop/src/app/useDaemonState.ts index eba4477..6b0bf62 100644 --- a/apps/desktop/src/app/useDaemonState.ts +++ b/apps/desktop/src/app/useDaemonState.ts @@ -10,6 +10,7 @@ import type { Project, Routine, } from "../protocol/entities"; +import { useDecisionNotificationsPref } from "../prefs"; import { errText } from "../util"; import type { Selection } from "./selection"; import { fetchActivity, fetchSnapshot } from "./snapshot"; @@ -94,6 +95,13 @@ export function useDaemonState(client: DaemonApi, addToast: AddToast): DaemonSta [botsRef], ); + const openDecision = useCallback( + (decisionId: string): void => { + select({ kind: "control", decisionId }); + }, + [select], + ); + const refreshActivity = useCallback((): void => { void fetchActivity(client).then(entities.applyActivity); // `applyActivity` is stable. @@ -109,6 +117,8 @@ export function useDaemonState(client: DaemonApi, addToast: AddToast): DaemonSta botName, onSelectBot: selectBot, refreshActivity, + onOpenDecision: openDecision, + decisionNotifications: useDecisionNotificationsPref(), }); return { diff --git a/apps/desktop/src/app/useDesktopShell.ts b/apps/desktop/src/app/useDesktopShell.ts index d9bc31b..dfcd4d8 100644 --- a/apps/desktop/src/app/useDesktopShell.ts +++ b/apps/desktop/src/app/useDesktopShell.ts @@ -6,8 +6,9 @@ import { useWindowShortcut } from "./useWindowShortcut"; /** What the desktop shell mirrors from app state: the dock badge and the global shortcut. */ export function useDesktopShell( unreadBots: Readonly>, + pendingDecisions: number, addToast: AddToast, ): void { - useDockBadge(unreadBots, useDockBadgePref()); + useDockBadge(unreadBots, pendingDecisions, useDockBadgePref()); useWindowShortcut(addToast); } diff --git a/apps/desktop/src/app/useDockBadge.test.ts b/apps/desktop/src/app/useDockBadge.test.ts index 8e93362..ba43cd6 100644 --- a/apps/desktop/src/app/useDockBadge.test.ts +++ b/apps/desktop/src/app/useDockBadge.test.ts @@ -8,10 +8,14 @@ vi.mock("../badge", () => ({ setDockBadge })); type Unread = Readonly>; /** Renders the hook so the caller can hand it a fresh set of counters. */ -function render(initialProps: Unread, enabled = true): { rerender: (next: Unread) => void } { +function render( + initialProps: Unread, + enabled = true, + pending = 0, +): { rerender: (next: Unread) => void } { return renderHook( (unread) => { - useDockBadge(unread, enabled); + useDockBadge(unread, pending, enabled); }, { initialProps }, ); @@ -59,3 +63,17 @@ describe("useDockBadge", () => { expect(setDockBadge).not.toHaveBeenCalledWith(2); }); }); + +describe("the badge counts decisions alongside messages", () => { + it("adds pending decisions to the unread total", () => { + setDockBadge.mockClear(); + render({ b1: 2 }, true, 3); + expect(setDockBadge).toHaveBeenLastCalledWith(5); + }); + + it("stays dark when the preference is off", () => { + setDockBadge.mockClear(); + render({ b1: 2 }, false, 3); + expect(setDockBadge).toHaveBeenLastCalledWith(0); + }); +}); diff --git a/apps/desktop/src/app/useDockBadge.ts b/apps/desktop/src/app/useDockBadge.ts index e2c988a..68e08b6 100644 --- a/apps/desktop/src/app/useDockBadge.ts +++ b/apps/desktop/src/app/useDockBadge.ts @@ -10,9 +10,19 @@ export function totalUnread(unreadBots: Readonly>): numbe return total; } -/** Mirrors the unread total onto the dock icon whenever it moves. */ -export function useDockBadge(unreadBots: Readonly>, enabled: boolean): void { - const total = enabled ? totalUnread(unreadBots) : 0; +/** + * Mirrors what wants the owner onto the dock icon whenever it moves. + * + * Pending decisions count alongside unread messages: a bot waiting on a ruling + * is asking for attention just as much as one that has said something, and the + * whole point of the registry is that such a question is never invisible. + */ +export function useDockBadge( + unreadBots: Readonly>, + pendingDecisions: number, + enabled: boolean, +): void { + const total = enabled ? totalUnread(unreadBots) + pendingDecisions : 0; useEffect(() => { void setDockBadge(total); }, [total]); diff --git a/apps/desktop/src/app/usePaletteActions.ts b/apps/desktop/src/app/usePaletteActions.ts index 007932c..6083d48 100644 --- a/apps/desktop/src/app/usePaletteActions.ts +++ b/apps/desktop/src/app/usePaletteActions.ts @@ -25,6 +25,14 @@ export function usePaletteActions(deps: PaletteDeps): readonly PaletteAction[] { select({ kind: "bot", botId: bot.id }); }, })), + { + id: "open-control-center", + label: "Control center", + hint: "open view", + run: (): void => { + select({ kind: "control" }); + }, + }, { id: "open-settings", label: "Settings", diff --git a/apps/desktop/src/app/usePendingDecisions.ts b/apps/desktop/src/app/usePendingDecisions.ts new file mode 100644 index 0000000..ff5a664 --- /dev/null +++ b/apps/desktop/src/app/usePendingDecisions.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from "react"; +import { useLoadOnConnect } from "../hooks/useLoadOnConnect"; +import type { DaemonApi } from "../protocol/api"; +import type { PendingCounts } from "../protocol/decisions"; + +const EMPTY: PendingCounts = { by_project: {}, total: 0, urgent: 0, due_soon: 0 }; + +/** + * How many decisions are waiting on the owner, for the sidebar row and the + * dock badge. + * + * Counted by the daemon rather than derived from a loaded list, so the badge is + * right before the Control center has ever been opened — which is the whole + * point of it: a question a bot asked while nobody was looking should be + * visible without going to find it. + * + * A daemon without the `decisions` capability answers with an error; that + * degrades to zero rather than failing the connection. + */ +export function usePendingDecisions(client: DaemonApi, connected: boolean): PendingCounts { + const [counts, setCounts] = useState(EMPTY); + + const load = useCallback(async (): Promise => { + try { + const reply = await client.request({ type: "count_pending_decisions" }, "pending_decisions"); + setCounts(reply.counts); + } catch { + setCounts(EMPTY); + } + }, [client]); + + useLoadOnConnect(connected, load); + + // Any change to any decision can move the count, and the pushes are cheap; + // recounting is one query and keeps the badge honest without a poll. + useEffect(() => { + const off = [ + client.on("decision_update", () => void load()), + client.on("decision_deleted", () => void load()), + ]; + return () => { + for (const unsubscribe of off) { + unsubscribe(); + } + }; + }, [client, load]); + + return counts; +} diff --git a/apps/desktop/src/components/MainPane.tsx b/apps/desktop/src/components/MainPane.tsx index 0494a5e..764130e 100644 --- a/apps/desktop/src/components/MainPane.tsx +++ b/apps/desktop/src/components/MainPane.tsx @@ -6,6 +6,7 @@ import quietArt from "../assets/empty/quiet.png"; import type { DaemonApi } from "../protocol/api"; import { connectionStatusLabel } from "../protocol/connection"; import BotView from "./BotView"; +import ControlCenterView from "./control/ControlCenterView"; import ProjectView from "./ProjectView"; import NewProjectForm from "./sidebar/NewProjectForm"; @@ -15,6 +16,7 @@ interface MainPaneProps { readonly addToast: AddToast; readonly onCreateProject: (name: string) => Promise; readonly onRenameProject: (projectId: string, name: string) => Promise; + readonly onSetProjectLead: (projectId: string, botId: string | null) => Promise; readonly onDeleteProject: (projectId: string) => Promise; } @@ -66,6 +68,20 @@ export default function MainPane(props: MainPaneProps): ReactElement { const { client, daemon, addToast } = props; const { selection, bots, connected, canControl } = daemon; + if (selection.kind === "control") { + return ( + + ); + } + if (selection.kind === "project") { const project = daemon.projects.find((item) => item.id === selection.projectId); return project === undefined ? ( @@ -78,6 +94,7 @@ export default function MainPane(props: MainPaneProps): ReactElement { connected={connected} canControl={canControl} onRename={props.onRenameProject} + onSetLead={props.onSetProjectLead} onDelete={props.onDeleteProject} /> ); diff --git a/apps/desktop/src/components/ProjectView.test.tsx b/apps/desktop/src/components/ProjectView.test.tsx index 57a980f..69a8553 100644 --- a/apps/desktop/src/components/ProjectView.test.tsx +++ b/apps/desktop/src/components/ProjectView.test.tsx @@ -9,6 +9,9 @@ function renderView(over: Partial[0]> = {}) { Promise.resolve(), ); const onDelete = vi.fn<(projectId: string) => Promise>(() => Promise.resolve()); + const onSetLead = vi.fn<(projectId: string, botId: string | null) => Promise>(() => + Promise.resolve(), + ); render( [0]> = {}) { connected canControl onRename={onRename} + onSetLead={onSetLead} onDelete={onDelete} {...over} />, ); - return { onRename, onDelete }; + return { onRename, onSetLead, onDelete }; } describe("ProjectView", () => { @@ -69,3 +73,17 @@ describe("ProjectView", () => { expect(screen.queryByRole("button", { name: "Delete project" })).not.toBeInTheDocument(); }); }); + +describe("the lead bot picker", () => { + it("names the bot that must know about every decision here", async () => { + const { onSetLead } = renderView(); + await userEvent.selectOptions(screen.getByLabelText(/Lead bot/), "b1"); + expect(onSetLead).toHaveBeenCalledWith("p1", "b1"); + }); + + it("falls back to whoever hired the asking bot when cleared", async () => { + const { onSetLead } = renderView({ project: fx.project({ lead_bot_id: "b1" }) }); + await userEvent.selectOptions(screen.getByLabelText(/Lead bot/), ""); + expect(onSetLead).toHaveBeenCalledWith("p1", null); + }); +}); diff --git a/apps/desktop/src/components/ProjectView.tsx b/apps/desktop/src/components/ProjectView.tsx index e40bc8c..9cc144c 100644 --- a/apps/desktop/src/components/ProjectView.tsx +++ b/apps/desktop/src/components/ProjectView.tsx @@ -9,6 +9,7 @@ interface ProjectViewProps { readonly connected: boolean; readonly canControl: boolean; readonly onRename: (projectId: string, name: string) => Promise; + readonly onSetLead: (projectId: string, botId: string | null) => Promise; readonly onDelete: (projectId: string) => Promise; } @@ -23,7 +24,7 @@ export function deletionBody(project: Project, botCount: number): string { /** Project settings: the name, what it holds, and deletion. */ export default function ProjectView(props: ProjectViewProps): ReactElement { - const { project, bots, connected, canControl, onRename, onDelete } = props; + const { project, bots, connected, canControl, onRename, onSetLead, onDelete } = props; const [name, setName] = useState(project.name); const [saving, setSaving] = useState(false); const [confirming, setConfirming] = useState(false); @@ -90,6 +91,30 @@ export default function ProjectView(props: ProjectViewProps): ReactElement { + +
Folder
{project.dir_name}
diff --git a/apps/desktop/src/components/Sidebar.test.tsx b/apps/desktop/src/components/Sidebar.test.tsx index 36fdfa5..8a5e6cb 100644 --- a/apps/desktop/src/components/Sidebar.test.tsx +++ b/apps/desktop/src/components/Sidebar.test.tsx @@ -1,6 +1,7 @@ import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; +import * as dfx from "../test/decisionFixtures"; import * as fx from "../test/fixtures"; import { renderSidebar } from "../test/sidebar"; import { stubLocalStorage } from "../test/spies"; @@ -261,4 +262,26 @@ describe("Sidebar", () => { renderSidebar({ status: "auth_failed" }); expect(screen.getByText(/auth failed/)).toBeInTheDocument(); }); + + it("badges the control center quietly when nothing is urgent", () => { + renderSidebar({ pendingDecisions: dfx.pendingCounts({ total: 3 }) }); + const pill = screen.getByText("3", { selector: ".control-row-pill" }); + expect(pill).not.toHaveClass("control-row-pill-urgent"); + expect(document.querySelector(".control-row-dot")).toBeNull(); + }); + + it("turns the control center red only for urgency", () => { + renderSidebar({ pendingDecisions: dfx.pendingCounts({ total: 3, urgent: 1, due_soon: 2 }) }); + expect(screen.getByText("3", { selector: ".control-row-pill" })).toHaveClass( + "control-row-pill-urgent", + ); + expect(document.querySelector(".control-row-dot")).not.toBeNull(); + expect(screen.getByTitle("1 urgent, 2 due within a day")).toBeInTheDocument(); + }); + + it("shows no control center count when nothing waits", () => { + renderSidebar(); + expect(document.querySelector(".control-row-count")).toBeNull(); + expect(screen.getByTitle("Decisions waiting on you")).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index b82169b..220e30a 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import type { ReactElement } from "react"; import { usePinnedBots } from "../app/usePinnedBots"; import type { ConnectionStatus, Endpoint } from "../protocol/connection"; +import type { PendingCounts } from "../protocol/decisions"; import type { Bot, Project } from "../protocol/entities"; import { PlusIcon, SearchIcon } from "./sidebar/icons"; import NewProjectForm from "./sidebar/NewProjectForm"; +import ControlCenterRow from "./sidebar/ControlCenterRow"; import ProjectSection from "./sidebar/ProjectSection"; import SidebarFooter from "./sidebar/SidebarFooter"; import type { SidebarTreeProps } from "./sidebar/tree"; @@ -16,6 +18,7 @@ type SidebarProps = Omit void; + readonly pendingDecisions: PendingCounts; readonly onCreateProject: (name: string) => Promise; /** The tree gets the sidebar's wrapper around this, which tracks the request. */ readonly onCreateBot: (projectId: string) => Promise; @@ -75,6 +78,12 @@ export default function Sidebar(props: SidebarProps): ReactElement { + onSelect({ kind: "control" })} + /> + {creatingProject ? ( ) : null} diff --git a/apps/desktop/src/components/control/Composer.stories.tsx b/apps/desktop/src/components/control/Composer.stories.tsx new file mode 100644 index 0000000..e548f5e --- /dev/null +++ b/apps/desktop/src/components/control/Composer.stories.tsx @@ -0,0 +1,51 @@ +import type { Story } from "@ladle/react"; +import { useRef, useState } from "react"; +import type { ReactElement } from "react"; +import { decision } from "../../test/decisionFixtures"; +import Composer from "./Composer"; +import { useComposer } from "./useComposer"; + +const noop = (): void => {}; +const subject = decision({ id: "d1" }); + +interface DemoProps { + readonly pick?: string; + readonly hold?: boolean; +} + +/** The composer is driven by `useComposer`, so the stories drive it too. */ +function Demo({ pick, hold = false }: DemoProps): ReactElement { + const composer = useComposer("d1"); + const ref = useRef(null); + const [applied, setApplied] = useState(false); + if (!applied) { + setApplied(true); + if (pick !== undefined) { + composer.togglePick(pick); + } + if (hold) { + composer.setHoldOpen(true); + } + } + + return ( +
+ +
+ ); +} + +export const Blank: Story = () => ; + +export const Picked: Story = () => ; + +export const HoldOpen: Story = () => ; diff --git a/apps/desktop/src/components/control/Composer.test.tsx b/apps/desktop/src/components/control/Composer.test.tsx new file mode 100644 index 0000000..6f05378 --- /dev/null +++ b/apps/desktop/src/components/control/Composer.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { decision } from "../../test/decisionFixtures"; +import Composer from "./Composer"; +import { useComposer } from "./useComposer"; + +const subject = decision({ id: "d1" }); + +interface HarnessProps { + readonly canControl?: boolean; + readonly busy?: boolean; + readonly onSave: () => void; + readonly onAsk: () => void; + readonly onHold: () => void; +} + +/** The real composer state, so the toggles behave as they do in the view. */ +function Harness({ + canControl = true, + busy = false, + onSave, + onAsk, + onHold, +}: HarnessProps): ReactElement { + const composer = useComposer("d1"); + const ref = useRef(null); + return ( + <> + + + + ); +} + +interface Spies { + readonly onSave: ReturnType void>>; + readonly onAsk: ReturnType void>>; + readonly onHold: ReturnType void>>; +} + +function renderComposer(over: Partial = {}): Spies { + const spies: Spies = { + onSave: vi.fn<() => void>(), + onAsk: vi.fn<() => void>(), + onHold: vi.fn<() => void>(), + }; + render(); + return spies; +} + +describe("Composer", () => { + it("will not save an empty ruling, but an option alone will do", async () => { + const user = userEvent.setup(); + renderComposer(); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "pick start" })); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeEnabled(); + // Asking still needs a question. + expect(screen.getByRole("button", { name: "Ask in thread" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "pick start" })); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeDisabled(); + await user.type(screen.getByLabelText("Your ruling"), "Start today."); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeEnabled(); + }); + + it("hands the words to the view", async () => { + const user = userEvent.setup(); + const spies = renderComposer(); + await user.type(screen.getByLabelText("Your ruling"), "Start today."); + + await user.click(screen.getByRole("button", { name: "Ask in thread" })); + expect(spies.onAsk).toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: /Save ruling/ })); + expect(spies.onSave).toHaveBeenCalled(); + }); + + it("opens the hold strip from Later", async () => { + const user = userEvent.setup(); + const spies = renderComposer(); + expect(screen.queryByLabelText("Hold until")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Later" })); + await user.click(screen.getByRole("button", { name: "Hold" })); + expect(spies.onHold).toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Later" })); + expect(screen.queryByLabelText("Hold until")).not.toBeInTheDocument(); + }); + + it("shows the picked option and clears it", async () => { + const user = userEvent.setup(); + renderComposer(); + await user.click(screen.getByRole("button", { name: "pick start" })); + + expect(screen.getByText("Start today")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "clear" })); + expect(screen.queryByText("Start today")).not.toBeInTheDocument(); + }); + + it("says what the words are for, picked or not", async () => { + const user = userEvent.setup(); + renderComposer(); + expect(screen.getByLabelText("Your ruling")).toHaveAttribute( + "placeholder", + "auction will quote this verbatim. Pick an option, write, or both.", + ); + + await user.click(screen.getByRole("button", { name: "pick start" })); + expect(screen.getByLabelText("Your ruling")).toHaveAttribute( + "placeholder", + "Why start, or any conditions (optional). auction will quote this verbatim.", + ); + }); + + it("is inert for a read-only client", () => { + renderComposer({ canControl: false }); + expect(screen.getByLabelText("Your ruling")).toBeDisabled(); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Ask in thread" })).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/components/control/Composer.tsx b/apps/desktop/src/components/control/Composer.tsx new file mode 100644 index 0000000..9f80dde --- /dev/null +++ b/apps/desktop/src/components/control/Composer.tsx @@ -0,0 +1,124 @@ +import type { ReactElement, RefObject } from "react"; +import type { Decision } from "../../protocol/decisions"; +import type { Composer as ComposerState } from "./useComposer"; + +interface ComposerProps { + readonly decision: Decision; + readonly composer: ComposerState; + readonly canControl: boolean; + readonly busy: boolean; + readonly textareaRef: RefObject; + readonly onSave: () => void; + readonly onAsk: () => void; + readonly onHold: () => void; +} + +/** + * Where the ruling is written. + * + * Picking an option is never enough on its own: the bots quote the words, so + * the button stays dead until there are some. + */ +export default function Composer({ + decision, + composer, + canControl, + busy, + textareaRef, + onSave, + onAsk, + onHold, +}: ComposerProps): ReactElement { + const picked = decision.options.find((option) => option.key === composer.pick); + const asker = decision.raised_by.name; + const placeholder = + picked === undefined + ? `${asker} will quote this verbatim. Pick an option, write, or both.` + : `Why ${picked.key}, or any conditions (optional). ${asker} will quote this verbatim.`; + const hasWords = composer.text.trim().length > 0; + // A picked option is a ruling on its own: its label becomes the words. + const ready = hasWords || picked !== undefined; + + return ( +
+
+
+ {picked === undefined ? null : ( +
+ + {picked.key} + {picked.label} + + +
+ )} +