From 506eb86380e2d24ce74b2bbc4f5211ac82cd4fe9 Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:54:40 +0400 Subject: [PATCH] feat(plugin): implement the Topic Stream UI --- .env.example | 1 + .github/workflows/ci.yml | 24 +- .github/workflows/release-plugin.yml | 1 + .gitignore | 1 + .impeccable/design.json | 270 + AGENTS.md | 4 + DESIGN.md | 709 +- PRODUCT.md | 12 +- README.md | 63 +- devctl.yaml | 4 +- ...2-contract-first-rest-with-advisory-sse.md | 8 + docs/development.md | 80 + docs/guides/getting-started.md | 45 +- docs/guides/using-topic-stream.md | 266 +- docs/operations/configuration.md | 20 + docs/plugin-quality.md | 83 + .../capabilities/digests-and-schedules.md | 2 +- docs/product/capabilities/durable-jobs.md | 3 +- .../product/capabilities/item-ai-summaries.md | 5 +- docs/product/capabilities/obsidian-feed.md | 151 +- .../capabilities/search-reader-and-events.md | 15 +- docs/releases.md | 3 + gen/config/config.gen.go | 5 +- go.mod | 1 + go.sum | 2 + internal/deps/api.go | 8 +- internal/deps/api_test.go | 20 +- internal/deps/cors.go | 22 + internal/deps/providers.go | 2 +- internal/platform/config/load.go | 5 + internal/platform/config/load_test.go | 30 + internal/transport/httpapi/cors.go | 28 + internal/transport/httpapi/cors_test.go | 114 + internal/transport/httpapi/router.go | 4 + ...src-features-shell-topicstreamshell-tsx.md | 131 + plugin/.storybook/main.ts | 14 + plugin/.storybook/obsidian-theme.css | 64 + plugin/.storybook/preview.ts | 18 + plugin/esbuild.config.mjs | 1 + plugin/eslint.config.mjs | 62 + plugin/manifest.json | 2 +- plugin/package.json | 55 +- plugin/scripts/api.mjs | 18 + plugin/scripts/check-mobile.mjs | 19 + plugin/scripts/install-browser.mjs | 9 + plugin/scripts/run-e2e-headless.mjs | 31 + plugin/src/api/archive-jobs-client.test.ts | 19 + plugin/src/api/client-errors.test.ts | 5 +- plugin/src/api/client.ts | 179 +- plugin/src/api/connection.test.ts | 32 + plugin/src/api/contracts.ts | 25 + plugin/src/api/errors.ts | 38 +- plugin/src/api/feed-client.test.ts | 37 + plugin/src/api/generated/openapi.ts | 2575 +++++ plugin/src/api/reader-client.test.ts | 26 + plugin/src/api/search-client.test.ts | 22 + plugin/src/api/settings-client.test.ts | 97 + plugin/src/api/summary-client.test.ts | 19 + plugin/src/api/transport.ts | 37 + plugin/src/api/types.ts | 91 - plugin/src/api/url.ts | 21 +- .../src/features/connection/ConnectedView.tsx | 28 + .../src/features/connection/session.test.ts | 107 + plugin/src/features/connection/session.ts | 80 + .../connection/workspace-state.test.ts | 32 + .../features/connection/workspace-state.ts | 22 + plugin/src/features/digests/ArchiveBuilds.tsx | 36 + plugin/src/features/digests/DigestEdition.tsx | 53 + plugin/src/features/digests/DigestReader.tsx | 14 + .../digests/DigestsWorkspace.stories.tsx | 257 + .../src/features/digests/DigestsWorkspace.tsx | 81 + plugin/src/features/digests/readiness.test.ts | 171 + plugin/src/features/digests/readiness.ts | 117 + plugin/src/features/digests/saved-result.ts | 18 + plugin/src/features/digests/services.ts | 2 + .../feed/FeedCapabilities.stories.tsx | 151 + plugin/src/features/feed/FeedCard.stories.tsx | 112 + plugin/src/features/feed/FeedCard.tsx | 45 + plugin/src/features/feed/FeedMessage.tsx | 8 + plugin/src/features/feed/FeedRow.stories.tsx | 40 + plugin/src/features/feed/FeedRow.tsx | 23 + plugin/src/features/feed/FeedView.tsx | 197 + .../features/feed/FeedWorkspace.stories.tsx | 738 ++ plugin/src/features/feed/FeedbackNote.tsx | 55 + .../features/feed/TopicSwitcher.stories.tsx | 69 + plugin/src/features/feed/TopicSwitcher.tsx | 87 + plugin/src/features/feed/cache.test.ts | 20 + plugin/src/features/feed/cache.ts | 13 + plugin/src/features/feed/capabilities.ts | 17 + plugin/src/features/feed/feed-updates.ts | 37 + plugin/src/features/feed/item-actions.tsx | 47 + plugin/src/features/feed/item-feedback.tsx | 92 + plugin/src/features/feed/item-reading.ts | 27 + plugin/src/features/feed/preferences.ts | 6 + plugin/src/features/feed/reconcile-feed.ts | 28 + plugin/src/features/feed/refresh-feed.ts | 27 + plugin/src/features/feed/services.ts | 3 + plugin/src/features/feed/summary-action.tsx | 64 + .../src/features/feed/topic-corrections.tsx | 54 + plugin/src/features/feed/unread-membership.ts | 27 + plugin/src/features/feed/vault-action.tsx | 24 + .../reader/CapturedReader.stories.tsx | 47 + .../features/reader/ItemSummary.stories.tsx | 153 + plugin/src/features/reader/ReaderBody.tsx | 101 + plugin/src/features/reader/ReaderImage.tsx | 28 + .../reader/ReaderWorkspace.stories.tsx | 188 + plugin/src/features/reader/SummaryBody.tsx | 22 + plugin/src/features/reader/images.test.ts | 66 + plugin/src/features/reader/images.ts | 86 + .../features/reader/item-summaries.test.ts | 125 + plugin/src/features/reader/item-summaries.ts | 112 + plugin/src/features/reader/markdown.ts | 8 + .../features/reader/prepare-markdown.test.ts | 60 + .../src/features/reader/prepare-markdown.ts | 84 + plugin/src/features/search/SearchFilters.tsx | 95 + plugin/src/features/search/SearchView.tsx | 66 + .../search/SearchWorkspace.stories.tsx | 325 + plugin/src/features/search/services.ts | 8 + .../src/features/settings/BackendStatus.tsx | 18 + .../features/settings/DigestBuildControl.tsx | 23 + .../src/features/settings/DigestCalendar.tsx | 36 + .../settings/DigestConflictReview.tsx | 13 + .../features/settings/DigestDefinitionRow.tsx | 36 + .../features/settings/DigestDefinitions.tsx | 65 + .../features/settings/DigestDeleteDialog.tsx | 12 + .../settings/DigestEditor.stories.tsx | 78 + plugin/src/features/settings/DigestEditor.tsx | 64 + plugin/src/features/settings/DigestTopics.tsx | 23 + plugin/src/features/settings/EditorPane.tsx | 5 + .../settings/EmbeddingModelControl.tsx | 101 + .../src/features/settings/ModelJobActions.tsx | 53 + .../settings/ModelReplacementDialog.tsx | 10 + .../PreferenceProfileEditor.stories.tsx | 72 + .../settings/PreferenceProfileEditor.tsx | 74 + .../features/settings/SettingsTextField.tsx | 11 + .../settings/SettingsWorkspace.stories.tsx | 390 + .../features/settings/SettingsWorkspace.tsx | 40 + .../features/settings/SourceDeleteDialog.tsx | 20 + .../settings/SourceEditor.stories.tsx | 103 + plugin/src/features/settings/SourceEditor.tsx | 55 + plugin/src/features/settings/SourceFields.tsx | 24 + plugin/src/features/settings/SourcePicker.tsx | 33 + plugin/src/features/settings/SourceRow.tsx | 38 + .../features/settings/SourceSyncStatus.tsx | 28 + .../src/features/settings/SourcesSection.tsx | 90 + .../src/features/settings/TopicAdvanced.tsx | 19 + .../features/settings/TopicDeleteDialog.tsx | 18 + .../features/settings/TopicEditor.stories.tsx | 156 + plugin/src/features/settings/TopicEditor.tsx | 109 + plugin/src/features/settings/TopicRow.tsx | 20 + .../src/features/settings/TopicsSection.tsx | 60 + .../features/settings/WorkspaceAdvanced.tsx | 18 + .../settings/WorkspaceConflictReview.tsx | 13 + .../settings/WorkspaceGenerationFields.tsx | 20 + .../WorkspaceSettingsEditor.stories.tsx | 141 + .../settings/WorkspaceSettingsEditor.tsx | 70 + .../features/settings/device-state.test.ts | 49 + plugin/src/features/settings/device-state.ts | 112 + .../settings/digest-build-resources.ts | 16 + .../features/settings/digest-builds.test.ts | 69 + plugin/src/features/settings/digest-builds.ts | 82 + plugin/src/features/settings/digest-draft.ts | 24 + plugin/src/features/settings/editor-exit.tsx | 42 + .../src/features/settings/persistence.test.ts | 65 + plugin/src/features/settings/persistence.ts | 70 + .../src/features/settings/preferences.test.ts | 21 + plugin/src/features/settings/preferences.ts | 23 + plugin/src/features/settings/services.ts | 22 + .../features/settings/source-actions.test.ts | 76 + .../src/features/settings/source-actions.ts | 74 + plugin/src/features/settings/source-cache.ts | 9 + plugin/src/features/settings/source-draft.ts | 29 + plugin/src/features/settings/topic-cache.ts | 9 + plugin/src/features/settings/topic-draft.ts | 14 + .../settings/transition-notifications.test.ts | 67 + .../settings/transition-notifications.ts | 59 + .../settings/use-transition-observation.ts | 27 + .../src/features/settings/workspace-draft.ts | 21 + .../features/shell/ConnectedShell.stories.tsx | 66 + plugin/src/features/shell/ConnectedShell.tsx | 75 + plugin/src/features/shell/SplitPane.tsx | 54 + .../shell/TopicStreamShell.stories.tsx | 138 + .../src/features/shell/TopicStreamShell.tsx | 70 + .../src/features/shell/ViewErrorBoundary.tsx | 13 + plugin/src/features/shell/navigation.test.ts | 75 + plugin/src/features/shell/navigation.ts | 107 + .../snapshots/IssueSnapshotAction.tsx | 14 + .../snapshots/SnapshotForm.stories.tsx | 105 + .../src/features/snapshots/SnapshotForm.tsx | 71 + plugin/src/features/snapshots/attachments.ts | 61 + plugin/src/features/snapshots/markdown.ts | 57 + .../src/features/snapshots/snapshot.test.ts | 154 + plugin/src/features/snapshots/snapshot.ts | 73 + plugin/src/features/snapshots/svg.ts | 18 + .../snapshots/use-snapshot-workflow.tsx | 41 + plugin/src/features/tasks/TaskActions.tsx | 45 + .../src/features/tasks/TaskModelContext.tsx | 17 + plugin/src/features/tasks/TaskSavedIssue.tsx | 11 + .../features/tasks/TasksWorkspace.stories.tsx | 215 + plugin/src/features/tasks/TasksWorkspace.tsx | 60 + plugin/src/features/tasks/services.ts | 2 + plugin/src/main.ts | 23 +- .../obsidian/services/event-stream.test.ts | 53 + plugin/src/obsidian/services/event-stream.ts | 31 + .../src/obsidian/services/external-links.ts | 13 + plugin/src/obsidian/services/http.test.ts | 16 + plugin/src/obsidian/services/http.ts | 5 + plugin/src/obsidian/services/markdown.test.ts | 53 + plugin/src/obsidian/services/markdown.ts | 14 + .../src/obsidian/services/snapshots.test.ts | 64 + plugin/src/obsidian/services/snapshots.ts | 46 + plugin/src/settings.ts | 84 +- plugin/src/shared/advisory-connection.test.ts | 82 + plugin/src/shared/advisory-connection.ts | 69 + plugin/src/shared/components/ActionMenu.tsx | 39 + .../components/ApiErrorMessage.stories.tsx | 29 + .../components}/ApiErrorMessage.test.tsx | 2 +- .../components}/ApiErrorMessage.tsx | 2 +- plugin/src/shared/components/IconButton.tsx | 35 + plugin/src/shared/components/MenuSurface.tsx | 66 + .../src/shared/components/anchored-surface.ts | 18 + plugin/src/shared/event-stream.ts | 3 + plugin/src/shared/events.ts | 10 + .../src/shared/jobs/JobProgress.stories.tsx | 18 + plugin/src/shared/jobs/JobProgress.tsx | 15 + plugin/src/shared/jobs/cache.ts | 6 + plugin/src/shared/jobs/job-actions.test.ts | 51 + plugin/src/shared/jobs/job-actions.ts | 36 + plugin/src/shared/jobs/kinds.ts | 2 + plugin/src/shared/jobs/resources.test.ts | 73 + plugin/src/shared/jobs/resources.ts | 16 + plugin/src/shared/sse-decoder.ts | 65 + .../test/storybook/FixtureProbe.stories.tsx | 82 + plugin/src/test/storybook/FixtureProbe.tsx | 100 + plugin/src/test/storybook/decorators.tsx | 11 + plugin/src/test/storybook/deferred.test.ts | 25 + plugin/src/test/storybook/deferred.ts | 12 + .../src/test/storybook/fake-services.test.ts | 29 + plugin/src/test/storybook/fake-services.ts | 29 + plugin/src/test/storybook/feed-preferences.ts | 11 + plugin/src/test/storybook/fixtures.ts | 143 + plugin/src/test/storybook/host-frame.css | 32 + plugin/src/test/storybook/host-frames.tsx | 13 + plugin/src/test/storybook/images.ts | 7 + .../test/storybook/interactive-scenario.ts | 134 + plugin/src/test/storybook/local-state.ts | 8 + plugin/src/test/storybook/markdown.ts | 30 + plugin/src/test/storybook/providers.tsx | 10 + plugin/src/test/storybook/scenario.test.ts | 48 + plugin/src/test/storybook/scenario.ts | 206 + .../src/test/storybook/showcase-scenario.ts | 70 + plugin/src/test/storybook/snapshot-vault.ts | 15 + plugin/src/view/FeedItemView.test.tsx | 59 + plugin/src/view/FeedItemView.tsx | 62 +- plugin/src/view/TopicStreamView.tsx | 263 - plugin/styles.css | 724 +- plugin/test/e2e/foundation.e2e.mjs | 505 + plugin/test/e2e/sse-feasibility.e2e.mjs | 66 + plugin/test/vault/Welcome.md | 3 + plugin/tsconfig.json | 6 +- plugin/versions.json | 2 +- plugin/vitest.config.ts | 21 + plugin/wdio.conf.mjs | 30 + pnpm-lock.yaml | 9847 +++++++++++++++-- pnpm-workspace.yaml | 3 + 265 files changed, 26817 insertions(+), 1770 deletions(-) create mode 100644 .impeccable/design.json create mode 100644 docs/plugin-quality.md create mode 100644 internal/deps/cors.go create mode 100644 internal/transport/httpapi/cors.go create mode 100644 internal/transport/httpapi/cors_test.go create mode 100644 plugin/.impeccable/surfaces/plugin-src-features-shell-topicstreamshell-tsx.md create mode 100644 plugin/.storybook/main.ts create mode 100644 plugin/.storybook/obsidian-theme.css create mode 100644 plugin/.storybook/preview.ts create mode 100644 plugin/eslint.config.mjs create mode 100644 plugin/scripts/api.mjs create mode 100644 plugin/scripts/check-mobile.mjs create mode 100644 plugin/scripts/install-browser.mjs create mode 100644 plugin/scripts/run-e2e-headless.mjs create mode 100644 plugin/src/api/archive-jobs-client.test.ts create mode 100644 plugin/src/api/connection.test.ts create mode 100644 plugin/src/api/contracts.ts create mode 100644 plugin/src/api/feed-client.test.ts create mode 100644 plugin/src/api/generated/openapi.ts create mode 100644 plugin/src/api/reader-client.test.ts create mode 100644 plugin/src/api/search-client.test.ts create mode 100644 plugin/src/api/settings-client.test.ts create mode 100644 plugin/src/api/summary-client.test.ts create mode 100644 plugin/src/api/transport.ts delete mode 100644 plugin/src/api/types.ts create mode 100644 plugin/src/features/connection/ConnectedView.tsx create mode 100644 plugin/src/features/connection/session.test.ts create mode 100644 plugin/src/features/connection/session.ts create mode 100644 plugin/src/features/connection/workspace-state.test.ts create mode 100644 plugin/src/features/connection/workspace-state.ts create mode 100644 plugin/src/features/digests/ArchiveBuilds.tsx create mode 100644 plugin/src/features/digests/DigestEdition.tsx create mode 100644 plugin/src/features/digests/DigestReader.tsx create mode 100644 plugin/src/features/digests/DigestsWorkspace.stories.tsx create mode 100644 plugin/src/features/digests/DigestsWorkspace.tsx create mode 100644 plugin/src/features/digests/readiness.test.ts create mode 100644 plugin/src/features/digests/readiness.ts create mode 100644 plugin/src/features/digests/saved-result.ts create mode 100644 plugin/src/features/digests/services.ts create mode 100644 plugin/src/features/feed/FeedCapabilities.stories.tsx create mode 100644 plugin/src/features/feed/FeedCard.stories.tsx create mode 100644 plugin/src/features/feed/FeedCard.tsx create mode 100644 plugin/src/features/feed/FeedMessage.tsx create mode 100644 plugin/src/features/feed/FeedRow.stories.tsx create mode 100644 plugin/src/features/feed/FeedRow.tsx create mode 100644 plugin/src/features/feed/FeedView.tsx create mode 100644 plugin/src/features/feed/FeedWorkspace.stories.tsx create mode 100644 plugin/src/features/feed/FeedbackNote.tsx create mode 100644 plugin/src/features/feed/TopicSwitcher.stories.tsx create mode 100644 plugin/src/features/feed/TopicSwitcher.tsx create mode 100644 plugin/src/features/feed/cache.test.ts create mode 100644 plugin/src/features/feed/cache.ts create mode 100644 plugin/src/features/feed/capabilities.ts create mode 100644 plugin/src/features/feed/feed-updates.ts create mode 100644 plugin/src/features/feed/item-actions.tsx create mode 100644 plugin/src/features/feed/item-feedback.tsx create mode 100644 plugin/src/features/feed/item-reading.ts create mode 100644 plugin/src/features/feed/preferences.ts create mode 100644 plugin/src/features/feed/reconcile-feed.ts create mode 100644 plugin/src/features/feed/refresh-feed.ts create mode 100644 plugin/src/features/feed/services.ts create mode 100644 plugin/src/features/feed/summary-action.tsx create mode 100644 plugin/src/features/feed/topic-corrections.tsx create mode 100644 plugin/src/features/feed/unread-membership.ts create mode 100644 plugin/src/features/feed/vault-action.tsx create mode 100644 plugin/src/features/reader/CapturedReader.stories.tsx create mode 100644 plugin/src/features/reader/ItemSummary.stories.tsx create mode 100644 plugin/src/features/reader/ReaderBody.tsx create mode 100644 plugin/src/features/reader/ReaderImage.tsx create mode 100644 plugin/src/features/reader/ReaderWorkspace.stories.tsx create mode 100644 plugin/src/features/reader/SummaryBody.tsx create mode 100644 plugin/src/features/reader/images.test.ts create mode 100644 plugin/src/features/reader/images.ts create mode 100644 plugin/src/features/reader/item-summaries.test.ts create mode 100644 plugin/src/features/reader/item-summaries.ts create mode 100644 plugin/src/features/reader/markdown.ts create mode 100644 plugin/src/features/reader/prepare-markdown.test.ts create mode 100644 plugin/src/features/reader/prepare-markdown.ts create mode 100644 plugin/src/features/search/SearchFilters.tsx create mode 100644 plugin/src/features/search/SearchView.tsx create mode 100644 plugin/src/features/search/SearchWorkspace.stories.tsx create mode 100644 plugin/src/features/search/services.ts create mode 100644 plugin/src/features/settings/BackendStatus.tsx create mode 100644 plugin/src/features/settings/DigestBuildControl.tsx create mode 100644 plugin/src/features/settings/DigestCalendar.tsx create mode 100644 plugin/src/features/settings/DigestConflictReview.tsx create mode 100644 plugin/src/features/settings/DigestDefinitionRow.tsx create mode 100644 plugin/src/features/settings/DigestDefinitions.tsx create mode 100644 plugin/src/features/settings/DigestDeleteDialog.tsx create mode 100644 plugin/src/features/settings/DigestEditor.stories.tsx create mode 100644 plugin/src/features/settings/DigestEditor.tsx create mode 100644 plugin/src/features/settings/DigestTopics.tsx create mode 100644 plugin/src/features/settings/EditorPane.tsx create mode 100644 plugin/src/features/settings/EmbeddingModelControl.tsx create mode 100644 plugin/src/features/settings/ModelJobActions.tsx create mode 100644 plugin/src/features/settings/ModelReplacementDialog.tsx create mode 100644 plugin/src/features/settings/PreferenceProfileEditor.stories.tsx create mode 100644 plugin/src/features/settings/PreferenceProfileEditor.tsx create mode 100644 plugin/src/features/settings/SettingsTextField.tsx create mode 100644 plugin/src/features/settings/SettingsWorkspace.stories.tsx create mode 100644 plugin/src/features/settings/SettingsWorkspace.tsx create mode 100644 plugin/src/features/settings/SourceDeleteDialog.tsx create mode 100644 plugin/src/features/settings/SourceEditor.stories.tsx create mode 100644 plugin/src/features/settings/SourceEditor.tsx create mode 100644 plugin/src/features/settings/SourceFields.tsx create mode 100644 plugin/src/features/settings/SourcePicker.tsx create mode 100644 plugin/src/features/settings/SourceRow.tsx create mode 100644 plugin/src/features/settings/SourceSyncStatus.tsx create mode 100644 plugin/src/features/settings/SourcesSection.tsx create mode 100644 plugin/src/features/settings/TopicAdvanced.tsx create mode 100644 plugin/src/features/settings/TopicDeleteDialog.tsx create mode 100644 plugin/src/features/settings/TopicEditor.stories.tsx create mode 100644 plugin/src/features/settings/TopicEditor.tsx create mode 100644 plugin/src/features/settings/TopicRow.tsx create mode 100644 plugin/src/features/settings/TopicsSection.tsx create mode 100644 plugin/src/features/settings/WorkspaceAdvanced.tsx create mode 100644 plugin/src/features/settings/WorkspaceConflictReview.tsx create mode 100644 plugin/src/features/settings/WorkspaceGenerationFields.tsx create mode 100644 plugin/src/features/settings/WorkspaceSettingsEditor.stories.tsx create mode 100644 plugin/src/features/settings/WorkspaceSettingsEditor.tsx create mode 100644 plugin/src/features/settings/device-state.test.ts create mode 100644 plugin/src/features/settings/device-state.ts create mode 100644 plugin/src/features/settings/digest-build-resources.ts create mode 100644 plugin/src/features/settings/digest-builds.test.ts create mode 100644 plugin/src/features/settings/digest-builds.ts create mode 100644 plugin/src/features/settings/digest-draft.ts create mode 100644 plugin/src/features/settings/editor-exit.tsx create mode 100644 plugin/src/features/settings/persistence.test.ts create mode 100644 plugin/src/features/settings/persistence.ts create mode 100644 plugin/src/features/settings/preferences.test.ts create mode 100644 plugin/src/features/settings/preferences.ts create mode 100644 plugin/src/features/settings/services.ts create mode 100644 plugin/src/features/settings/source-actions.test.ts create mode 100644 plugin/src/features/settings/source-actions.ts create mode 100644 plugin/src/features/settings/source-cache.ts create mode 100644 plugin/src/features/settings/source-draft.ts create mode 100644 plugin/src/features/settings/topic-cache.ts create mode 100644 plugin/src/features/settings/topic-draft.ts create mode 100644 plugin/src/features/settings/transition-notifications.test.ts create mode 100644 plugin/src/features/settings/transition-notifications.ts create mode 100644 plugin/src/features/settings/use-transition-observation.ts create mode 100644 plugin/src/features/settings/workspace-draft.ts create mode 100644 plugin/src/features/shell/ConnectedShell.stories.tsx create mode 100644 plugin/src/features/shell/ConnectedShell.tsx create mode 100644 plugin/src/features/shell/SplitPane.tsx create mode 100644 plugin/src/features/shell/TopicStreamShell.stories.tsx create mode 100644 plugin/src/features/shell/TopicStreamShell.tsx create mode 100644 plugin/src/features/shell/ViewErrorBoundary.tsx create mode 100644 plugin/src/features/shell/navigation.test.ts create mode 100644 plugin/src/features/shell/navigation.ts create mode 100644 plugin/src/features/snapshots/IssueSnapshotAction.tsx create mode 100644 plugin/src/features/snapshots/SnapshotForm.stories.tsx create mode 100644 plugin/src/features/snapshots/SnapshotForm.tsx create mode 100644 plugin/src/features/snapshots/attachments.ts create mode 100644 plugin/src/features/snapshots/markdown.ts create mode 100644 plugin/src/features/snapshots/snapshot.test.ts create mode 100644 plugin/src/features/snapshots/snapshot.ts create mode 100644 plugin/src/features/snapshots/svg.ts create mode 100644 plugin/src/features/snapshots/use-snapshot-workflow.tsx create mode 100644 plugin/src/features/tasks/TaskActions.tsx create mode 100644 plugin/src/features/tasks/TaskModelContext.tsx create mode 100644 plugin/src/features/tasks/TaskSavedIssue.tsx create mode 100644 plugin/src/features/tasks/TasksWorkspace.stories.tsx create mode 100644 plugin/src/features/tasks/TasksWorkspace.tsx create mode 100644 plugin/src/features/tasks/services.ts create mode 100644 plugin/src/obsidian/services/event-stream.test.ts create mode 100644 plugin/src/obsidian/services/event-stream.ts create mode 100644 plugin/src/obsidian/services/external-links.ts create mode 100644 plugin/src/obsidian/services/http.test.ts create mode 100644 plugin/src/obsidian/services/http.ts create mode 100644 plugin/src/obsidian/services/markdown.test.ts create mode 100644 plugin/src/obsidian/services/markdown.ts create mode 100644 plugin/src/obsidian/services/snapshots.test.ts create mode 100644 plugin/src/obsidian/services/snapshots.ts create mode 100644 plugin/src/shared/advisory-connection.test.ts create mode 100644 plugin/src/shared/advisory-connection.ts create mode 100644 plugin/src/shared/components/ActionMenu.tsx create mode 100644 plugin/src/shared/components/ApiErrorMessage.stories.tsx rename plugin/src/{view => shared/components}/ApiErrorMessage.test.tsx (97%) rename plugin/src/{view => shared/components}/ApiErrorMessage.tsx (95%) create mode 100644 plugin/src/shared/components/IconButton.tsx create mode 100644 plugin/src/shared/components/MenuSurface.tsx create mode 100644 plugin/src/shared/components/anchored-surface.ts create mode 100644 plugin/src/shared/event-stream.ts create mode 100644 plugin/src/shared/events.ts create mode 100644 plugin/src/shared/jobs/JobProgress.stories.tsx create mode 100644 plugin/src/shared/jobs/JobProgress.tsx create mode 100644 plugin/src/shared/jobs/cache.ts create mode 100644 plugin/src/shared/jobs/job-actions.test.ts create mode 100644 plugin/src/shared/jobs/job-actions.ts create mode 100644 plugin/src/shared/jobs/kinds.ts create mode 100644 plugin/src/shared/jobs/resources.test.ts create mode 100644 plugin/src/shared/jobs/resources.ts create mode 100644 plugin/src/shared/sse-decoder.ts create mode 100644 plugin/src/test/storybook/FixtureProbe.stories.tsx create mode 100644 plugin/src/test/storybook/FixtureProbe.tsx create mode 100644 plugin/src/test/storybook/decorators.tsx create mode 100644 plugin/src/test/storybook/deferred.test.ts create mode 100644 plugin/src/test/storybook/deferred.ts create mode 100644 plugin/src/test/storybook/fake-services.test.ts create mode 100644 plugin/src/test/storybook/fake-services.ts create mode 100644 plugin/src/test/storybook/feed-preferences.ts create mode 100644 plugin/src/test/storybook/fixtures.ts create mode 100644 plugin/src/test/storybook/host-frame.css create mode 100644 plugin/src/test/storybook/host-frames.tsx create mode 100644 plugin/src/test/storybook/images.ts create mode 100644 plugin/src/test/storybook/interactive-scenario.ts create mode 100644 plugin/src/test/storybook/local-state.ts create mode 100644 plugin/src/test/storybook/markdown.ts create mode 100644 plugin/src/test/storybook/providers.tsx create mode 100644 plugin/src/test/storybook/scenario.test.ts create mode 100644 plugin/src/test/storybook/scenario.ts create mode 100644 plugin/src/test/storybook/showcase-scenario.ts create mode 100644 plugin/src/test/storybook/snapshot-vault.ts create mode 100644 plugin/src/view/FeedItemView.test.tsx delete mode 100644 plugin/src/view/TopicStreamView.tsx create mode 100644 plugin/test/e2e/foundation.e2e.mjs create mode 100644 plugin/test/e2e/sse-feasibility.e2e.mjs create mode 100644 plugin/test/vault/Welcome.md create mode 100644 plugin/vitest.config.ts create mode 100644 plugin/wdio.conf.mjs diff --git a/.env.example b/.env.example index 7b29dcb..6ba3166 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ TOPIC_STREAM_BOOTSTRAP_EMBEDDING_MODEL=qwen3-embedding:0.6b TOPIC_STREAM_COMPOSE_DB_PRIMARY_POSTGRES_DSN=postgres://topic_stream:topic_stream@postgres:5432/topic_stream?sslmode=disable TOPIC_STREAM_COMPOSE_HTTP_ADDR=0.0.0.0:7331 TOPIC_STREAM_COMPOSE_QDRANT_URL=http://qdrant:6334 +TOPIC_STREAM_CORS_ALLOWED_ORIGINS=app://obsidian.md TOPIC_STREAM_DB_PRIMARY_KIND=postgres TOPIC_STREAM_DB_PRIMARY_POSTGRES_DSN= TOPIC_STREAM_EMBEDDING_BATCH_INPUT_BYTES=1048576 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c379c91..749f3c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,9 @@ jobs: needs: changes if: needs.changes.outputs.plugin == 'true' runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 20 + env: + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.artifacts/playwright steps: - uses: actions/checkout@v7 with: @@ -230,9 +232,28 @@ jobs: with: node-version: '24' cache: pnpm + - name: Cache Playwright browsers + uses: actions/cache@v6 + with: + path: ${{ github.workspace }}/.artifacts/playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- - run: pnpm install --frozen-lockfile + - run: pnpm --dir plugin exec playwright install --with-deps chromium - run: pnpm --dir plugin check + - run: pnpm --dir plugin test:storybook + - run: pnpm --dir plugin build:storybook - run: pnpm --dir plugin build + - name: Production Obsidian lifecycle and product flows (headless) + run: pnpm --dir plugin test:e2e:headless + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: plugin-host-diagnostics-${{ inputs.source_sha || github.sha }} + path: .artifacts/obsidian/logs/ + retention-days: 7 + if-no-files-found: ignore contract: name: Current contract and generated source @@ -257,6 +278,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm contract:lint + - run: pnpm --dir plugin api:check - name: Validate and regenerate owned outputs run: | mise run codegen diff --git a/.github/workflows/release-plugin.yml b/.github/workflows/release-plugin.yml index da8cc09..39332e3 100644 --- a/.github/workflows/release-plugin.yml +++ b/.github/workflows/release-plugin.yml @@ -113,6 +113,7 @@ jobs: test -s release-assets/main.js test -s release-assets/manifest.json test -s release-assets/styles.css + cmp plugin/manifest.json release-assets/manifest.json remote_tag=$(git ls-remote origin "refs/tags/$TAG") if [ -n "$remote_tag" ]; then diff --git a/.gitignore b/.gitignore index 24faef3..42d30fe 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ coverage.out *.tmp __pycache__/ .artifacts/ +.impeccable/review/ # Local product issues .scratch/ diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..cc2408f --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,270 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-10-28T19:12:52Z", + "title": "Design System: Topic Stream", + "extensions": { + "colorMeta": { + "paper-light": { "role": "neutral", "displayName": "Light Paper" }, + "paper-dark": { "role": "neutral", "displayName": "Dark Paper" }, + "panel-light": { "role": "neutral", "displayName": "Light Panel" }, + "panel-dark": { "role": "neutral", "displayName": "Dark Panel" }, + "field-light": { "role": "neutral", "displayName": "Light Field" }, + "field-dark": { "role": "neutral", "displayName": "Dark Field" }, + "hover-light": { "role": "neutral", "displayName": "Light Hover" }, + "hover-dark": { "role": "neutral", "displayName": "Dark Hover" }, + "ink-light": { "role": "neutral", "displayName": "Light Ink" }, + "ink-dark": { "role": "neutral", "displayName": "Dark Ink" }, + "muted-light": { "role": "neutral", "displayName": "Light Muted" }, + "muted-dark": { "role": "neutral", "displayName": "Dark Muted" }, + "line-light": { "role": "neutral", "displayName": "Light Line" }, + "line-dark": { "role": "neutral", "displayName": "Dark Line" }, + "error-light": { "role": "semantic", "displayName": "Light Error" }, + "error-dark": { "role": "semantic", "displayName": "Dark Error" }, + "accent-light": { "role": "primary", "displayName": "Light Theme Accent Ink" }, + "accent-dark": { "role": "primary", "displayName": "Dark Theme Accent" }, + "accent-fill": { "role": "primary", "displayName": "Soft Peach Selection" }, + "accent-hover": { "role": "primary", "displayName": "Peach Hover" }, + "accent-soft-light": { "role": "primary", "displayName": "Light Peach Wash" }, + "accent-soft-dark": { "role": "primary", "displayName": "Dark Peach Wash" }, + "accent-ink": { "role": "neutral", "displayName": "Selected Row Ink" }, + "selected-excerpt-ink": { "role": "neutral", "displayName": "Selected Excerpt Ink" }, + "glass-light": { "role": "overlay", "displayName": "Light Glass Fallback" }, + "glass-dark": { "role": "overlay", "displayName": "Dark Glass Fallback" }, + "selection": { "role": "primary", "displayName": "Selection" } + }, + "typographyMeta": { + "body": { "displayName": "Interface Body", "purpose": "Host-native application copy and controls." }, + "display": { "displayName": "Display / H1", "purpose": "One focal page or reading title." }, + "title": { "displayName": "Title / H2", "purpose": "Section and content titles." }, + "subsection": { "displayName": "Subsection / H3", "purpose": "Smaller resource headings." }, + "prose": { "displayName": "Reading Prose", "purpose": "Article content with a comfortable reading measure." }, + "metadata": { "displayName": "Metadata", "purpose": "Source, date, Topic, feedback, and status context." }, + "settings-label": { "displayName": "Settings Label", "purpose": "Muted labels above settings form fields at 0.8125em." }, + "note-label": { "displayName": "Note Label", "purpose": "The optional note prompt at 1em within the 14px note panel." } + }, + "shadows": [ + { "name": "light-overlay", "value": "0 16px 48px rgb(33 24 18 / 16%)", "purpose": "Action menus, feedback notes, and opaque dialogs in light mode." }, + { "name": "dark-overlay", "value": "0 20px 56px rgb(0 0 0 / 38%)", "purpose": "Action menus, feedback notes, and opaque dialogs in dark mode." }, + { "name": "editor-edge", "value": "-16px 0 48px rgb(0 0 0 / 18%)", "purpose": "Desktop editor pane separation; mobile removes it." } + ], + "motion": [ + { "name": "hover-transition", "value": "150ms ease-out", "purpose": "Background, text-color, and border-color state changes." }, + { "name": "reduced-motion", "value": "scroll-behavior: auto; transition: none; animation: none", "purpose": "Reduced-motion preference for every Topic Stream descendant." } + ], + "breakpoints": [ + { "name": "single-pane-leaf", "value": "48em" } + ], + "focus": { + "width": "2px", + "offset": "3px", + "color": "var(--topic-stream-accent)" + }, + "touchTarget": "44px" + }, + "components": [ + { + "name": "Primary navigation", + "kind": "nav", + "refersTo": "primary-navigation-selected", + "description": "The active reading section uses an ink-filled tab; secondary sections remain quiet. At constrained widths, complete tab controls wrap while labels stay whole.", + "html": "", + "css": ".ds-nav{display:flex;flex-wrap:wrap;max-width:100%;gap:6px}.ds-nav button{font:inherit;font-size:.875em;min-height:40px;padding:9px 16px;border:0;border-radius:13px;background:transparent;color:var(--topic-stream-muted,#68615c);white-space:nowrap;cursor:pointer;transition:background-color 150ms ease-out,color 150ms ease-out}.ds-nav button:hover{background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f)}.ds-nav button[aria-selected=\"true\"]{background:var(--topic-stream-ink,#24211f);color:var(--topic-stream-panel,#fff);font-weight:550}.ds-nav button:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Icon button", + "kind": "button", + "refersTo": "icon-button", + "description": "A named toolbar action with a quiet field surface and a pressed peach state.", + "html": "", + "css": ".ds-icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:40px;min-width:40px;min-height:40px;padding:10px;border:0;border-radius:13px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-muted,#68615c);cursor:pointer;transition:background-color 150ms ease-out,color 150ms ease-out}.ds-icon:hover{background:var(--topic-stream-hover,#e9e5e0);color:var(--topic-stream-ink,#24211f)}.ds-icon[aria-pressed=\"true\"]{background:var(--topic-stream-accent-fill,#f2b38a);color:#241a13}.ds-icon:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}@media(pointer:coarse){.ds-icon{width:44px;min-width:44px;min-height:44px}}" + }, + { + "name": "Primary action", + "kind": "button", + "refersTo": "primary-action", + "description": "One clear peach action for save, add, or send operations.", + "html": "", + "css": ".ds-primary{font:inherit;min-height:42px;padding:10px 18px;border:0;border-radius:12px;background:var(--topic-stream-accent-fill,#f2b38a);color:#241a13;font-size:.875em;font-weight:550;cursor:pointer;transition:background-color 150ms ease-out}.ds-primary:hover{background:var(--topic-stream-accent-hover,#f7c5a6)}.ds-primary:disabled{opacity:.45;cursor:default}.ds-primary:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Secondary action", + "kind": "button", + "refersTo": "secondary-action", + "description": "A quiet text action for retry and other labelled secondary operations.", + "html": "", + "css": ".ds-secondary{font:inherit;min-height:40px;padding:10px 16px;border:0;border-radius:12px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);cursor:pointer;transition:background-color 150ms ease-out}.ds-secondary:hover{background:var(--topic-stream-hover,#e9e5e0)}.ds-secondary:disabled{opacity:.45;cursor:default}.ds-secondary:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Selected reading row", + "kind": "custom", + "refersTo": "selected-reading-row", + "description": "Text-led selection uses peach fill and a dedicated readable excerpt color.", + "html": "", + "css": ".ds-row{display:block;width:100%;padding:18px 16px;border:0;border-radius:17px;text-align:left;font:inherit;background:var(--topic-stream-selection,#f2b38a);color:#241a13;cursor:pointer;transition:background-color 150ms ease-out}.ds-row strong{display:block;font-weight:550;line-height:1.45;letter-spacing:-.015em}.ds-row span{display:block;margin-top:10px;color:#493124;font-size:.8125em;line-height:1.6}.ds-row:hover{background:var(--topic-stream-accent-hover,#f7c5a6)}.ds-row:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Feed header controls", + "kind": "custom", + "description": "A compact Feed heading, searchable Topic scope, refresh action, and persisted All/Unread filter; the header gains only a light blur while its list is scrolled. The refresh SVG is Lucide refresh-cw (ISC; attribution retained in production IconButton.tsx): https://github.com/lucide-icons/lucide/blob/main/icons/refresh-cw.svg", + "html": "

Feed

", + "css": ".ds-feed-list{position:relative;overflow:auto;background:var(--topic-stream-panel,#fff)}.ds-feed-header{position:sticky;top:0;z-index:1;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 10px 32px;background:transparent}.ds-feed-header:before{content:\"\";position:absolute;inset:-24px 0 0;z-index:-1;background:var(--topic-stream-panel,#fff)}.ds-feed-heading{display:flex;align-items:center;gap:10px;min-width:0;flex-wrap:wrap}.ds-feed-heading h2{margin:0;font-size:1.25em}.ds-topic,.ds-refresh,.ds-filter button{border:0;border-radius:12px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:inherit;min-height:40px;padding:8px 12px;cursor:pointer}.ds-topic{display:flex;align-items:center;gap:8px;max-width:15em;font-size:.8125em}.ds-refresh{width:40px;padding:10px;color:var(--topic-stream-muted,#68615c)}.ds-filter{display:flex;gap:4px;margin:0 10px 8px}.ds-filter button{border-radius:10px;background:transparent;color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-filter button[aria-pressed=\"true\"]{background:var(--topic-stream-accent-soft,#f8e8dc);color:var(--topic-stream-accent,#99562e)}.ds-topic:hover,.ds-refresh:hover,.ds-filter button:hover{background:var(--topic-stream-hover,#e9e5e0)}@supports((backdrop-filter:blur(12px)) or (-webkit-backdrop-filter:blur(12px))){.ds-feed-list[data-scrolled=\"true\"] .ds-feed-header:before{background:var(--topic-stream-glass,rgb(255 255 255 / 86%));-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}}.ds-topic:focus-visible,.ds-refresh:focus-visible,.ds-filter button:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Text field", + "kind": "input", + "refersTo": "text-field", + "description": "A borderless host-compatible field with a shared focus treatment.", + "html": "", + "css": ".ds-field{display:grid;gap:9px;font:inherit;font-size:.8125em;color:var(--topic-stream-muted,#68615c)}.ds-field input{font:inherit;font-size:var(--font-text-size,16px);min-width:0;min-height:44px;padding:12px 14px;border:0;border-radius:13px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);caret-color:var(--topic-stream-accent,#99562e)}.ds-field input::placeholder{color:var(--topic-stream-muted,#68615c);opacity:1}.ds-field input:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Anchored feedback menu", + "kind": "custom", + "refersTo": "action-menu", + "description": "Portalled ellipsis menu that owns immediate rating, reading, original-link, visibility, Topic-correction, details, and conditional note actions.", + "html": "
", + "css": ".ds-menu{width:208px;max-width:calc(100vw - 16px);padding:6px;border:0;border-radius:15px;background:var(--topic-stream-panel,#fff);box-shadow:0 16px 48px rgb(33 24 18 / 16%);font:14px var(--font-interface,system-ui,sans-serif)}.ds-menu button{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;min-height:38px;padding:9px 12px;border:0;border-radius:10px;background:transparent;color:inherit;text-align:left;font:inherit;cursor:pointer;transition:background-color 150ms ease-out}.ds-menu button:hover,.ds-menu button:focus-visible{outline:none;background:var(--topic-stream-accent-soft,#f8e8dc)}.ds-menu .ds-menu-separated{margin-top:6px}.ds-menu:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}@supports((backdrop-filter:blur(20px)) or (-webkit-backdrop-filter:blur(20px))){.ds-menu{background:var(--topic-stream-glass,rgb(255 255 255 / 86%));-webkit-backdrop-filter:blur(28px);backdrop-filter:blur(28px)}}" + }, + { + "name": "Feedback note panel", + "kind": "custom", + "refersTo": "feedback-note", + "description": "Nonmodal anchored note editor opened after a rating; Not for me can reveal a transient Topic correction while sending stays explicit and local states remain readable.", + "html": "

Not for me

", + "css": ".ds-note{width:360px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;padding:20px;border-radius:18px;background:var(--topic-stream-panel,#fff);box-shadow:0 16px 48px rgb(33 24 18 / 16%);font:14px var(--font-interface,system-ui,sans-serif);color:var(--topic-stream-ink,#24211f)}.ds-note-header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:12px}.ds-note h2{margin:0;font-size:1em}.ds-close,.ds-send{display:inline-flex;align-items:center;justify-content:center;width:40px;min-width:40px;min-height:40px;padding:10px;border:0;border-radius:13px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-muted,#68615c);cursor:pointer;transition:background-color 150ms ease-out,color 150ms ease-out}.ds-close:hover{background:var(--topic-stream-hover,#e9e5e0);color:var(--topic-stream-ink,#24211f)}.ds-comment{display:grid;gap:8px;color:var(--topic-stream-muted,#68615c)}.ds-comment textarea{width:100%;min-width:0;resize:vertical;padding:14px;border:0;border-radius:14px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:var(--font-text-size,16px) var(--font-interface,system-ui,sans-serif);line-height:1.6;caret-color:var(--topic-stream-accent,#99562e)}.ds-note-footer{display:flex;align-items:center;gap:12px;margin-top:12px}.ds-status{min-height:1em;flex:1;color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-correction{padding:6px 0;min-height:40px;border:0;background:transparent;color:var(--topic-stream-accent,#99562e);text-align:left;white-space:normal}.ds-send{background:var(--topic-stream-accent-fill,#f2b38a);color:#241a13}.ds-send:hover{background:var(--topic-stream-accent-hover,#f7c5a6)}.ds-close:focus-visible,.ds-send:focus-visible,.ds-correction:focus-visible,.ds-comment textarea:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}@supports((backdrop-filter:blur(20px)) or (-webkit-backdrop-filter:blur(20px))){.ds-note{background:var(--topic-stream-glass,rgb(255 255 255 / 86%));-webkit-backdrop-filter:blur(28px);backdrop-filter:blur(28px)}}" + }, + { + "name": "Search filter surface", + "kind": "custom", + "description": "A portalled, anchored filter form for Topic, Source, and UTC publication bounds with explicit Apply and clear actions.", + "html": "

Search filters

Publication dates use UTC.

", + "css": ".ds-search-filter{width:360px;max-width:calc(100vw - 16px);padding:20px;border-radius:18px;background:var(--topic-stream-panel,#fff);box-shadow:var(--topic-stream-shadow,0 16px 48px rgb(33 24 18 / 16%));font:14px var(--font-interface,system-ui,sans-serif);color:var(--topic-stream-ink,#24211f)}.ds-search-filter h2{margin:0 0 16px;font-size:1.25em}.ds-search-filter label{display:grid;gap:9px;margin-top:16px;color:var(--topic-stream-muted,#68615c)}.ds-search-filter select,.ds-search-filter input{min-height:44px;padding:12px 14px;border:0;border-radius:13px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:inherit}.ds-search-filter p{color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-search-actions{display:flex;gap:12px;justify-content:flex-end;margin-top:20px}.ds-search-actions button{min-height:40px;padding:10px 16px;border:0;border-radius:12px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:inherit}.ds-search-actions .ds-search-apply{background:var(--topic-stream-accent-fill,#f2b38a);color:#241a13}.ds-search-filter button:focus-visible,.ds-search-filter input:focus-visible,.ds-search-filter select:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}@supports((backdrop-filter:blur(20px)) or (-webkit-backdrop-filter:blur(20px))){.ds-search-filter{background:var(--topic-stream-glass,rgb(255 255 255 / 86%));-webkit-backdrop-filter:blur(28px);backdrop-filter:blur(28px)}}" + }, + { + "name": "Reader image consent placeholder", + "kind": "custom", + "description": "A matte, labelled placeholder that asks before loading every known image in the current Item.", + "html": "", + "css": ".ds-image-placeholder{display:block;width:100%;min-height:96px;padding:24px 20px;border:0;border-radius:14px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:14px var(--font-interface,system-ui,sans-serif);text-align:left;cursor:pointer}.ds-image-placeholder small{display:block;margin-top:6px;color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-image-placeholder:hover{background:var(--topic-stream-hover,#e9e5e0)}.ds-image-placeholder:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "AI summary disclosure", + "kind": "custom", + "description": "An explicit, exact-version Item disclosure with readable pending, ready, recovery, and retry states.", + "html": "

AI summary

Summary is being prepared.

", + "css": ".ds-summary{padding:16px;margin:12px 0;color:var(--topic-stream-ink,#24211f);font:14px var(--font-interface,system-ui,sans-serif);font-size:.9375em;line-height:1.65}.ds-summary h2{margin:0 0 12px;font-size:1em}.ds-summary-status{color:var(--topic-stream-muted,#68615c)}.ds-summary button{min-height:40px;padding:10px 16px;border:0;border-radius:12px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:inherit;cursor:pointer}.ds-summary button:hover{background:var(--topic-stream-hover,#e9e5e0)}.ds-summary button:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Digest archive row", + "kind": "custom", + "refersTo": "archive-row", + "description": "A text-led saved Issue row with definition/date metadata, partial status, and an unseen New badge.", + "html": "", + "css": ".ds-archive-row{display:block;width:100%;padding:18px 16px;border:0;border-radius:17px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);text-align:left;font:inherit;cursor:pointer;transition:background-color 150ms ease-out}.ds-archive-title{display:block;font-weight:550;line-height:1.45;letter-spacing:-.015em}.ds-archive-meta{display:flex;flex-wrap:wrap;gap:4px 12px;margin-top:12px;color:var(--topic-stream-muted,#68615c);font-size:.8125em;line-height:1.5}.ds-new{color:var(--topic-stream-accent,#99562e)}.ds-archive-row:hover{background:var(--topic-stream-hover,#e9e5e0)}.ds-archive-row:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Selected Issue contents item", + "kind": "custom", + "refersTo": "issue-contents-item-selected", + "description": "The compact contents list marks the captured Item currently open in the Reader with peach selection.", + "html": "", + "css": ".ds-contents-item{display:block;width:100%;min-height:44px;margin:4px 0;padding:12px;border:0;border-radius:12px;background:var(--topic-stream-accent-fill,#f2b38a);color:#241a13;text-align:left;font:inherit;cursor:pointer}.ds-contents-item:hover{background:var(--topic-stream-accent-hover,#f7c5a6)}.ds-contents-item:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}" + }, + { + "name": "Shared Job progress", + "kind": "custom", + "refersTo": "job-progress-fill", + "description": "Phase and initial-cohort progress shared by Digest Builds, Settings, and Task Center; ordinary failed/cancelled Jobs retain Retry, while model-preparation Retry additionally requires a verified current, eligible target. Model recovery, saved-result recovery, and status authority stay in the shared Job model.", + "html": "

Running

Indexing initial Items8 / 12 Items
", + "css": ".ds-job-progress{font:14px var(--font-interface,system-ui,sans-serif;color:var(--topic-stream-ink,#24211f)}.ds-job-progress p{margin:4px 0;color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-progress-caption{display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px 16px;margin-top:8px;color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-job-progress progress{appearance:none;width:100%;height:3px;border:0;border-radius:0;background:var(--topic-stream-line,#dedbd7);color:var(--topic-stream-accent,#99562e);accent-color:var(--topic-stream-accent,#99562e)}.ds-job-progress progress::-webkit-progress-bar{background:var(--topic-stream-line,#dedbd7)}.ds-job-progress progress::-webkit-progress-value{background:var(--topic-stream-accent,#99562e)}.ds-job-progress progress::-moz-progress-bar{background:var(--topic-stream-accent,#99562e)}.ds-job-progress:is([data-status=\"dead\"],[data-status=\"cancelled\"]) progress::-webkit-progress-value{background:var(--topic-stream-muted,#68615c)}.ds-job-progress:is([data-status=\"dead\"],[data-status=\"cancelled\"]) progress::-moz-progress-bar{background:var(--topic-stream-muted,#68615c)}" + }, + { + "name": "Snapshot Save form", + "kind": "custom", + "refersTo": "snapshot-dialog", + "description": "A native protected-focus Save to vault form for normalized paths, explicit content/image choices, mixed-result consent, and contextual recovery.", + "html": "

Save to vault

A closer look at retrieval systems

Content

Excerpts, source links and ready AI summaries.

", + "css": ".ds-snapshot{width:min(460px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;padding:28px;border-radius:22px;background:var(--topic-stream-panel,#fff);color:var(--topic-stream-ink,#24211f);font:14px var(--font-interface,system-ui,sans-serif);box-shadow:var(--topic-stream-shadow,0 16px 48px rgb(33 24 18 / 16%))}.ds-snapshot h2{margin:0 0 8px;font-size:1.375em;line-height:1.3}.ds-snapshot-title{margin:0 0 28px;color:var(--topic-stream-muted,#68615c);font-size:.875em}.ds-snapshot-field{display:grid;gap:9px;margin-top:16px;color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-snapshot-field input{min-width:0;min-height:44px;padding:12px 14px;border:0;border-radius:13px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:inherit;font-size:var(--font-text-size,16px)}.ds-snapshot-choice{display:flex;flex-wrap:wrap;gap:6px;padding:0;margin:20px 0 0;border:0}.ds-snapshot-choice legend{width:100%;margin-bottom:2px;color:var(--topic-stream-muted,#68615c);font-size:.8125em}.ds-snapshot-choice label{display:flex;align-items:center;gap:8px;min-height:44px;padding:10px 14px;border-radius:12px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-muted,#68615c)}.ds-snapshot-choice input{appearance:auto;width:16px;height:16px;min-height:16px;flex:0 0 16px;margin:0;accent-color:var(--topic-stream-accent,#99562e)}.ds-snapshot-hint{color:var(--topic-stream-muted,#68615c);line-height:1.65}.ds-snapshot-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.ds-snapshot-actions button{min-height:40px;padding:10px 16px;border:0;border-radius:12px;background:var(--topic-stream-field,#f0efed);color:var(--topic-stream-ink,#24211f);font:inherit}.ds-snapshot-actions .ds-snapshot-save{background:var(--topic-stream-accent-fill,#f2b38a);color:#241a13}.ds-snapshot button:focus-visible,.ds-snapshot input:focus-visible{outline:2px solid var(--topic-stream-accent,#99562e);outline-offset:3px}@supports((backdrop-filter:blur(20px)) or (-webkit-backdrop-filter:blur(20px))){.ds-snapshot{background:var(--topic-stream-glass,rgb(255 255 255 / 86%));-webkit-backdrop-filter:blur(28px);backdrop-filter:blur(28px)}}" + }, + { + "name": "Settings editor pane", + "kind": "custom", + "description": "A right-anchored desktop editor that becomes a full-width mobile surface.", + "html": "", + "css": ".ds-editor{width:min(560px,100%);padding:28px;border-radius:22px;background:var(--topic-stream-panel,#fff);box-shadow:-16px 0 48px rgb(0 0 0 / 18%);color:var(--topic-stream-ink,#24211f);font:inherit}.ds-editor p{color:var(--topic-stream-muted,#68615c);line-height:1.65}@supports((backdrop-filter:blur(20px)) or (-webkit-backdrop-filter:blur(20px))){.ds-editor{background:var(--topic-stream-glass,rgb(255 255 255 / 86%));-webkit-backdrop-filter:blur(28px);backdrop-filter:blur(28px)}}@media(max-width:48em){.ds-editor{width:100%;padding:24px 16px;box-shadow:none}}" + } + ], + "narrative": { + "northStar": "A Composed Tactile Reading Workspace", + "overview": "Topic Stream is a calm, direct control surface for a personal information-retrieval system. Its\nvisual authority is the user-pinned reference at\n`.artifacts/storybook-review/design-reference.png`, sourced from the approved\n[Dribbble image](https://cdn.dribbble.com/userupload/12942608/file/original-4c287785698d505006c3f207a6b77987.png?resize=2048x1536&vertical=center).\nThe implementation translates that reference into black and charcoal work areas, lighter matte\nfields, clear host typography, rounded work zones, and a soft peach action language. The reference\nis a direction anchor, not a pixel-bound asset or a generated concept.\n\nThe production shell is code-led and retains Obsidian's interface and reading typography. Real\nConnectedShell and production screen compositions render in Storybook; external services and data\nare mocked at their ports, and English/Spanish fixtures are synthetic demonstrations. The shipped\nslice includes the Feed's independent All Topics/Topic traversals, persisted All/Unread filtering,\ndirect-title reading and explicit Item details, immediate feedback with optional notes, visibility,\nand Topic corrections, the quiet Search + Filters flow, and the canonical Reader with full-body\nMarkdown, consent-gated images, and an explicit shared AI Summary disclosure. Sources, Topics,\nDigest definitions, Workspace, Preferences, and Status remain available as management surfaces.\nThe Reader preserves title-first hierarchy and Item-scoped notices; Search keeps text-led rows,\nanchored filters, UTC date bounds, 20-result pages, and a 100-result session cap. UI 08 also ships\nthe connected Digest archive, saved Edition with compact contents and captured Reader, saved-definition\nBuild and results, device-scoped New Issue badges, and the Task Center's shared Job progress,\nCancel/Retry actions, and model-recovery context. Device-scoped readiness establishes a baseline from\nthe first successful unfiltered archive head once per device/backend/Workspace; subsequent mount or\nmanual REST reads reconstruct unopened post-baseline Issues without emitting catch-up Notices. Live\nNotice/event consumers share a view-owned browser SSE transport and deduplicate across views.\nReconnect reconciles REST without publication, generation or global offline chrome; failed streams\nretain content and explicit Refresh. UI 09 also ships one native protected-focus Save to vault form from\nItem and captured Digest Item overflow menus plus the whole-Issue actions menu. It normalizes paths,\nremembers Compact/Full and Full-only image choices per vault, requires explicit mixed-Digest consent,\nand keeps validation, pending, and write errors contextual. Ready matching AI annotations are\ncaptured without generating, waiting, retrying, or mirroring later work; safe bounded Vault/FileManager\nwrites preserve links, sanitize SVG, reject overwrites, and roll back only newly created attachments.\n", + "keyCharacteristics": [ + "Black/charcoal work areas with pale light-mode equivalents and no decorative gradients.", + "Matte fields and rounded work zones, with translucent blur reserved for anchored menus, note panels, editor panes, and the Feed header while its list is scrolled.", + "A soft peach selection and action color (`#F2B38A`) with a lighter hover (`#F7C5A6`).", + "Host-native interface and reading type, a restrained spacing rhythm, and equal light/dark care.", + "One clear action per view; secondary operations stay in real anchored menus.", + "Title-first Reader content with bounded prose, local scrolling for code/tables, and quiet source headings beneath the Item title.", + "Consent-gated matte image placeholders and local recovery states; AI Summary remains explicit, version-bound, and readable when ready.", + "View-owned browser Fetch/ReadableStream SSE keeps invalidations advisory, reconciles REST on reconnect, and retains loaded content without a persistent offline banner.", + "Primary navigation wraps complete controls, while Settings headings/resource actions wrap as groups and narrow resource panels stretch to the available width.", + "Digest archive rows, Edition sections, compact contents, and captured Reader preserve the same text-led hierarchy while New badges identify unseen saved Issues.", + "Task Center keeps Job status, progress, history facts, Cancel/Retry, and model recovery in the shared shell rather than turning background work into a dashboard.", + "Snapshot saves use a native peach form for normalized paths, explicit content/image choices, mixed-result consent, and contextual recovery while restoring focus to the invoking overflow action." + ], + "rules": [ + { + "name": "The Purposeful Peach Rule", + "body": "Peach marks a current action, selected row, checked state, or focus; it is not a decorative wash across every title or surface. Host-native semantic warning colors may still appear for errors.", + "section": "colors" + }, + { + "name": "The One Focal Title Rule", + "body": "Give the current page or opened Item one clear title. Supporting metadata and controls stay quieter; hierarchy does not require inventing an editorial headline or an unsupported intelligence claim.", + "section": "typography" + }, + { + "name": "The Real Overlay Rule", + "body": "Use depth to clarify an anchored menu, note, dialog, or editor pane. Do not add shadows around every reading row or turn a matte field into a floating card.", + "section": "elevation" + }, + { + "name": "The Status-Authority Rule", + "body": "Treat the Job status label as the outcome; the progress bar only describes the observed initial cohort. Never infer success from a full bar or a count alone.", + "section": "components" + }, + { + "name": "The Protected Save Rule", + "body": "Snapshot Save stays an explicit, native, focus-protected action: normalize and reject paths before writing, preserve captured content, and keep mixed consent, pending, and failures visible without generating or waiting for AI work.", + "section": "components" + } + ], + "dos": [ + "derive new surfaces from the black/charcoal work-area hierarchy and the light-mode pale equivalent; keep fields matte and readable.", + "reserve `#F2B38A` and `#F7C5A6` for action, selection, checked/pressed, hover, and focus states; retain the light-theme accent ink where contrast requires it.", + "preserve host typography, theme switching, keyboard focus, `44px` coarse-pointer targets, and the `48em` leaf breakpoint.", + "keep menus, filters, and note panels anchored to their trigger, with panel fallback colors when blur is unavailable and `blur(28px)` only when supported.", + "keep the Feed header opaque at rest and use the existing glass token with `blur(12px)` only while its list is scrolled; preserve the opaque fallback and the `32px` header clearance.", + "preserve the Reader's title-first hierarchy, `70ch` prose measure, local code/table scroll, consent-gated images, and exact-version explicit summary disclosure.", + "keep Search text-led with quiet Search + Filters controls, UTC date semantics, 20-result pages, and a 100-result session limit.", + "keep Digest archive and Edition surfaces text-led, preserve compact contents and captured Reader return/focus, and show New Issue badges only for unseen device-scoped saved Issues.", + "keep Task Center and Settings on shared Job queries; use peach normal progress, muted failed/cancelled history, and explicit Cancel/Retry/model recovery actions.", + "keep view-owned browser Fetch/ReadableStream SSE advisory: bind reconnect and pop-out lifecycle to the active view, reconcile REST authoritatively, and retain loaded content plus explicit Refresh when streaming is unavailable.", + "keep Snapshot saves portable and explicit: normalize paths, reject existing destinations, preserve captured content, honor image budgets and SVG sanitation, and restore focus to the invoking action.", + "keep ready AI annotations source-version-bound in Compact or Full without generating, waiting, retrying, or mirroring later work; show mixed consent and Excerpt only labels when needed.", + "keep one clear action per view and use explicit text for unfamiliar distinctions.", + "keep Storybook evidence connected to production components, mocking only external service ports and using synthetic English/Spanish demonstration data." + ], + "donts": [ + "invent a competing palette or direction, or treat the pinned reference as a source of random generated comps.", + "add decorative gradients, glowing edges, ornamental glass, nested card borders, or shadows around every row.", + "replace real anchored menus with a permanent inline Add a note control, a modal note editor, or a row of outlined secondary actions.", + "claim physical mobile-device parity from responsive frames, browser dependency checks or desktop host tests; browser Fetch/ReadableStream SSE and native desktop main/pop-out lifecycle are shipped and verified, while physical iOS/Android streaming remains untested.", + "silently save an excerpt under Full, show Full-only image controls when an individual Item has no full text, overwrite an existing path, or imply that linked/failed images were stored locally.", + "trigger Summary generation, waiting, Retry, or late note mutation from Snapshot Save; capture only a ready annotation matching the saved Item input version.", + "infer Job success from `N / N`; preserve separate peach normal and muted failed/cancelled progress history fills with the status label as the authority.", + "fetch thumbnails before image consent, turn a ready summary into a generated headline, or display summary text from a different source version.", + "claim excerpts are generated summaries or add unsupported intelligence, KPI, ETA, or operator-tuning UI.", + "use the exploratory raster as a pixel-bound asset; keep the implementation code-led and preserve its provenance." + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index e78d5dc..6541ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,7 @@ Issues and specs are tracked as Markdown files under `.scratch//`. See ### Domain docs Domain documentation uses the single-context layout. See `docs/agents/domain.md`. + +### Storybook + +Product stories render production components and the same connected screen composition as the plugin. Mock external services and data at their ports. The primary Shell story includes every currently implemented screen and action; extend it with each feature. Keep harness-only probes hidden from the catalog. Multilingual demo content uses English and Spanish. diff --git a/DESIGN.md b/DESIGN.md index a27e78c..5deecb6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,147 +1,636 @@ --- -name: Topic Stream — Signal Desk -description: A calm, precise reading workspace with deliberate visual hierarchy and cool, restrained accents. +name: Topic Stream +description: A composed, tactile reading workspace with matte charcoal work areas, host-native type, and a soft peach action language. +colors: + paper-light: "#edeeed" + paper-dark: "#090909" + panel-light: "#ffffff" + panel-dark: "#191919" + field-light: "#f0efed" + field-dark: "#2b2a29" + hover-light: "#e9e5e0" + hover-dark: "#34312f" + ink-light: "#24211f" + ink-dark: "#f3f0ed" + muted-light: "#68615c" + muted-dark: "#b5aea8" + line-light: "#dedbd7" + line-dark: "#36322f" + error-light: "#b34040" + error-dark: "#f28b82" + accent-light: "#99562e" + accent-dark: "#f2b38a" + accent-fill: "#f2b38a" + accent-hover: "#f7c5a6" + accent-soft-light: "#f8e8dc" + accent-soft-dark: "#3b2b23" + accent-ink: "#241a13" + selected-excerpt-ink: "#493124" + glass-light: "rgb(255 255 255 / 86%)" + glass-dark: "rgb(35 33 31 / 82%)" + selection: "#f2b38a" +typography: + body: + fontFamily: "var(--font-interface, system-ui, sans-serif)" + fontSize: "var(--font-text-size, 16px)" + fontWeight: 400 + lineHeight: 1.5 + display: + fontFamily: "var(--font-interface, system-ui, sans-serif)" + fontSize: "2em" + fontWeight: 600 + lineHeight: 1.18 + letterSpacing: "-0.03em" + title: + fontFamily: "var(--font-interface, system-ui, sans-serif)" + fontSize: "1.375em" + fontWeight: 550 + lineHeight: 1.3 + letterSpacing: "-0.02em" + subsection: + fontFamily: "var(--font-interface, system-ui, sans-serif)" + fontSize: "1em" + fontWeight: 550 + lineHeight: 1.4 + prose: + fontFamily: "var(--font-text, var(--font-interface))" + fontSize: "var(--font-text-size, 16px)" + fontWeight: 400 + lineHeight: 1.75 + metadata: + fontFamily: "var(--font-interface, system-ui, sans-serif)" + fontSize: "0.8125em" + fontWeight: 400 + lineHeight: 1.5 + settings-label: + fontFamily: "var(--font-interface, system-ui, sans-serif)" + fontSize: "0.8125em" + fontWeight: 400 + lineHeight: 1.5 + note-label: + fontFamily: "var(--font-interface, system-ui, sans-serif)" + fontSize: "1em" + fontWeight: 400 + lineHeight: 1.5 +rounded: + checkbox: "5px" + chip: "8px" + menu-item: "10px" + control: "12px" + icon: "13px" + textarea: "14px" + menu: "15px" + reading-row: "17px" + note: "18px" + mobile-shell-header: "16px" + shell-header: "20px" + settings-nav: "18px" + work-area: "22px" +spacing: + hairline-gap: "4px" + compact-gap: "6px" + tight: "8px" + label-gap: "9px" + control-inset: "12px 14px" + group: "16px" + row-inset: "18px 16px" + panel: "20px" + section: "24px" + page: "28px 32px 40px" + reading-pane: "20px 32px 44px" + wide: "36px" +components: + primary-action: + backgroundColor: "{colors.accent-fill}" + textColor: "{colors.accent-ink}" + rounded: "{rounded.control}" + padding: "10px 18px" + height: "42px" + secondary-action: + backgroundColor: "{colors.field-light}" + textColor: "{colors.ink-light}" + rounded: "{rounded.control}" + padding: "10px 16px" + height: "40px" + icon-button: + backgroundColor: "{colors.field-light}" + textColor: "{colors.muted-light}" + rounded: "{rounded.icon}" + padding: "10px" + height: "40px" + width: "40px" + primary-navigation-selected: + backgroundColor: "{colors.ink-light}" + textColor: "{colors.panel-light}" + rounded: "{rounded.icon}" + padding: "9px 16px" + height: "40px" + selected-reading-row: + backgroundColor: "{colors.selection}" + textColor: "{colors.accent-ink}" + rounded: "{rounded.reading-row}" + padding: "{spacing.row-inset}" + text-field: + backgroundColor: "{colors.field-light}" + textColor: "{colors.ink-light}" + rounded: "{rounded.icon}" + padding: "{spacing.control-inset}" + height: "44px" + action-menu: + backgroundColor: "{colors.panel-light}" + textColor: "{colors.ink-light}" + rounded: "{rounded.menu}" + padding: "6px" + width: "208px" + feedback-note: + backgroundColor: "{colors.panel-light}" + textColor: "{colors.ink-light}" + rounded: "{rounded.note}" + padding: "{spacing.panel}" + width: "360px" + archive-row: + backgroundColor: "{colors.field-light}" + textColor: "{colors.ink-light}" + rounded: "{rounded.reading-row}" + padding: "{spacing.row-inset}" + issue-contents-item-selected: + backgroundColor: "{colors.accent-fill}" + textColor: "{colors.accent-ink}" + rounded: "{rounded.control}" + padding: "12px" + height: "44px" + new-issue-badge: + textColor: "{colors.accent-light}" + job-progress-fill: + backgroundColor: "{colors.accent-fill}" + height: "3px" + job-progress-history-fill: + backgroundColor: "{colors.muted-light}" + height: "3px" + snapshot-dialog: + backgroundColor: "{colors.panel-light}" + textColor: "{colors.ink-light}" + rounded: "{rounded.work-area}" + padding: "28px" + width: "460px" + snapshot-choice: + backgroundColor: "{colors.field-light}" + textColor: "{colors.muted-light}" + rounded: "{rounded.control}" + padding: "10px 14px" + height: "44px" --- - - # Design System: Topic Stream ## Overview -**Creative North Star: "Signal Desk"** - -Signal Desk is a light-feeling, technically precise reading workspace. Readwise Reader is a -reference for tonal restraint and reading focus, not a screen to copy. Beauty comes from clear -typographic roles, controlled density, generous reading space, and a few unmistakable accents. -The interface must feel composed rather than uniformly muted: the eye always has a point of entry. - -This is the accepted replacement direction, not a description of the shipped plugin. It supersedes -the incumbent warm-paper/cobalt appearance. These invariants guide implementation; exact color, -spacing, radius, and component tokens will be extracted from the reviewed implementation rather -than fabricated here. This seed records the accepted visual direction; the -[current Obsidian experience](docs/product/capabilities/obsidian-feed.md) describes the available -plugin behavior. +**Creative North Star: "A Composed Tactile Reading Workspace"** + +Topic Stream is a calm, direct control surface for a personal information-retrieval system. Its +visual authority is the user-pinned reference at +`.artifacts/storybook-review/design-reference.png`, sourced from the approved +[Dribbble image](https://cdn.dribbble.com/userupload/12942608/file/original-4c287785698d505006c3f207a6b77987.png?resize=2048x1536&vertical=center). +The implementation translates that reference into black and charcoal work areas, lighter matte +fields, clear host typography, rounded work zones, and a soft peach action language. The reference +is a direction anchor, not a pixel-bound asset or a generated concept. + +The production shell is code-led and retains Obsidian's interface and reading typography. Real +ConnectedShell and production screen compositions render in Storybook; external services and data +are mocked at their ports, and English/Spanish fixtures are synthetic demonstrations. The shipped +slice includes the Feed's independent All Topics/Topic traversals, persisted All/Unread filtering, +direct-title reading and explicit Item details, immediate feedback with optional notes, visibility, +and Topic corrections, the quiet Search + Filters flow, and the canonical Reader with full-body +Markdown, consent-gated images, and an explicit shared AI Summary disclosure. Sources, Topics, +Digest definitions, Workspace, Preferences, and Status remain available as management surfaces. +The Reader preserves title-first hierarchy and Item-scoped notices; Search keeps text-led rows, +anchored filters, UTC date bounds, 20-result pages, and a 100-result session cap. UI 08 also ships +the connected Digest archive, saved Edition with compact contents and captured Reader, saved-definition +Build and results, device-scoped New Issue badges, and the Task Center's shared Job progress, +Cancel/Retry actions, and model-recovery context. Device-scoped readiness establishes a baseline from +the first successful unfiltered archive head once per device/backend/Workspace; subsequent mount or +manual REST reads reconstruct unopened post-baseline Issues without emitting catch-up Notices. UI 11 +delivers live Notice/event consumers through an actual view-owned browser `fetch`/`ReadableStream` +SSE transport with bounded reconnect, cancellation, endpoint replacement, pop-out realm migration, +and cross-view deduplication. Reconnect reconciles REST resources without Feed publication, Summary +generation or global offline chrome; unavailable streams retain loaded content and explicit Refresh. +UI 09 also ships an explicit Save to vault workflow from Item and +captured Digest Item overflows plus the whole-Issue actions menu. Its native protected-focus form +normalizes folder and filename paths, remembers Compact/Full and Full-only Links/Remote/Local image +choices per vault, and keeps Save independent from backend state. Full-only image controls disappear +when an individual Item has no full text; mixed Full Digests require explicit consent and label missing +Items Excerpt only. Ready, source-version-matching AI annotations are captured when available, while +Save never generates, waits for, or mirrors later summary work. Portable writes use bounded local +attachments, safe SVG sanitation, overwrite checks, and rollback through the host Vault/FileManager +boundary; contextual validation, pending, and write errors stay in the form. **Key Characteristics:** -- Cool graphite and blue-gray neutrals, with a cool off-white light counterpart. -- One restrained periwinkle-blue accent with a clear interactive or selection purpose. -- Strong primary, secondary, and supporting type roles; quiet does not mean flat. -- Matte, mostly flat surfaces; separation through tone, spacing, and subtle seams. -- Obsidian-native typography, focus, theme integration, and desktop/mobile usability. +- Black/charcoal work areas with pale light-mode equivalents and no decorative gradients. +- Matte fields and rounded work zones, with translucent blur reserved for anchored menus, note + panels, editor panes, and the Feed header while its list is scrolled. +- A soft peach selection and action color (`#F2B38A`) with a lighter hover (`#F7C5A6`). +- Host-native interface and reading type, a restrained spacing rhythm, and equal light/dark care. +- One clear action per view; secondary operations stay in real anchored menus. +- Title-first Reader content with bounded prose, local scrolling for code/tables, and quiet source + headings beneath the Item title. +- Consent-gated matte image placeholders and local recovery states; AI Summary remains explicit, + version-bound, and readable when ready. +- View-owned browser SSE keeps live invalidations advisory: reconnect and unavailable-stream recovery + reconcile authoritative REST state without automatic Feed publication, Summary generation, or a + persistent offline banner. +- Primary navigation wraps complete controls, while Settings headings/resource actions wrap as groups + and narrow resource panels stretch to the available width. +- Digest archive rows, Edition sections, compact contents, and captured Reader preserve the same + text-led hierarchy while New badges identify unseen saved Issues. +- Task Center keeps Job status, progress, history facts, Cancel/Retry, and model recovery in the + shared shell rather than turning background work into a dashboard. +- Snapshot saves use one native, focus-protected peach form for normalized paths, explicit content and + image choices, mixed-result consent, and contextual recovery; Item and Issue overflow entry points + restore focus after dismissal. ## Colors +The palette has a near-black dark mode and a pale, warm-neutral light mode. Semantic roles keep +the same hierarchy across themes: paper is the root ground, panel is the work zone, field is the +control surface, ink is primary text, muted is supporting text, line is a quiet seam, and peach is +reserved for action, selection, and focus. The light accent is the darker `#99562e` text/link color; +dark mode uses the approved peach directly for the same role. + ### Primary -Use a cool periwinkle-blue for active navigation, selected controls, the main action, and link -hover/focus. Selection can combine a restrained tonal wash with a thin accent marker. Do not make -every content title accent-colored at rest: repeated color would erase the intended hierarchy. -Focus must remain visible independently of hover, and selection must not rely on color alone. +- **Soft Peach Selection** (`#F2B38A`): selected rows, checked/pressed controls, primary actions, + and the persistent selection token in both themes. +- **Peach Hover** (`#F7C5A6`): hover state for an already selected row or peach action. +- **Light Theme Accent Ink** (`#99562e`): readable light-theme links, focus, and action text. +- **Dark Theme Accent** (`#F2B38A`): dark-theme links, focus, and action text. ### Neutral -Dark mode uses graphite and blue-gray surfaces, off-white primary text, and softer supporting text. -Light mode uses cool off-white surfaces, graphite primary text, and readable muted text. Follow -Obsidian's active light/dark mode; neither mode is a secondary or reduced-quality variant. - -Exact values remain to be resolved during implementation against host CSS variables and contrast -checks. The plugin-owned root contains all visual overrides; other Obsidian surfaces remain intact. -Error and attention states retain semantic host colors and a textual explanation. - -**The Purposeful Accent Rule.** An accent identifies a current selection, an action, or a focus -state; it is not distributed evenly as decoration. Yellow, ochre, amber, and gold are excluded from -the brand accent palette. This does not prohibit host-native semantic warning treatment. +- **Light Paper** (`#edeeed`) and **Dark Paper** (`#090909`): root workspaces. +- **Light Panel** (`#ffffff`) and **Dark Panel** (`#191919`): separated list, detail, settings, + and dialog work zones. +- **Light Field** (`#f0efed`) and **Dark Field** (`#2b2a29`): matte inputs, rows, quiet buttons, + and controls. +- **Light Hover** (`#e9e5e0`) and **Dark Hover** (`#34312f`): neutral hover feedback. +- **Light Ink** (`#24211f`) and **Dark Ink** (`#f3f0ed`): primary text and the selected primary-nav + background/text pairing. +- **Light Muted** (`#68615c`) and **Dark Muted** (`#b5aea8`): metadata, labels, and supporting copy. +- **Light Line** (`#dedbd7`) and **Dark Line** (`#36322f`): seams, scrollbar tracks, and progress + tracks. +- **Light Error** (`#b34040`) and **Dark Error** (`#f28b82`): semantic error text and recovery copy. +- **Selected Row Ink** (`#241a13`) and **Selected Excerpt Ink** (`#493124`): readable text on peach + selection. +- **Light Glass** (`rgb(255 255 255 / 86%)`) and **Dark Glass** (`rgb(35 33 31 / 82%)`): translucent + overlay fallback colors when blur is supported. + +**The Purposeful Peach Rule.** Peach marks a current action, selected row, checked state, or focus; +it is not a decorative wash across every title or surface. Host-native semantic warning colors may +still appear for errors. ## Typography -Use Obsidian's interface font stack for neutral, readable UI typography, with no external font -downloads. Reader content respects the host's reading typography and scaling. Keep interface copy -in sentence case; avoid decorative uppercase and excessively tracked labels. +**Display Font:** `var(--font-interface, system-ui, sans-serif)` + +**Body Font:** `var(--font-interface, system-ui, sans-serif)` with +`var(--font-text, var(--font-interface))` for reading prose. -The following are target sizes at the normal host scale, not extracted CSS tokens. Implement them -with scalable units and preserve host font preferences and zoom: +**Character:** Obsidian supplies the actual face and scaling. Topic Stream establishes hierarchy +through size, weight, line-height, and small negative tracking rather than a downloaded display font +or decorative lettering. Interface copy remains sentence case. -- **Primary title:** roughly 30–32 px, semibold. One dominant title in the active content pane. -- **Content title:** roughly 18–20 px, medium or semibold, visibly subordinate to the primary title. -- **Body / summary:** roughly 16–18 px, regular, with comfortable line spacing and softer foreground. -- **Section label / metadata:** roughly 12–14 px, readable but quiet; a Topic label is not another headline. -- **Navigation list title:** roughly 15–16 px, medium; selection carries emphasis without display-sized type. +### Hierarchy -**The One Focal Title Rule.** In a Digest Issue, the edition title leads; the Digest Definition name -and date are supporting metadata, Topics are section markers, and Item titles form the second -level. In Reader, the opened Item title becomes the focal title and adjacent contents recede. -Preserve the backend's actual titles; hierarchy does not require a new editorial headline or API field. +- **Display / H1** (600, `2em`, `1.18` line-height, `-0.03em`): the primary page or reading title. +- **Title / H2** (550, `1.375em`, `1.3` line-height, `-0.02em`): section and content titles. +- **Subsection / H3** (550, `1em`, `1.4` line-height): smaller resource headings. +- **Body** (400, `var(--font-text-size, 16px)`, `1.5` line-height): interface copy and ordinary + content; reading prose uses `1.75` line-height and a `70ch` maximum. +- **Metadata** (400, `0.8125em`, `1.5` line-height): source, date, Topic, feedback, and status + context. +- **Settings label** (400, `0.8125em`, `1.5` line-height): muted labels above settings form + fields. +- **Note label** (400, `1em`, `1.5` line-height): the optional-note prompt inside the `14px` + feedback panel. -Optional AI text uses a quiet, explicit `AI summary` label and the normal body role, never a -second focal headline. Pending and error labels stay small, readable and contextual; no sparkle -branding, promotional badge or fabricated progress is needed to identify generated text. +**The One Focal Title Rule.** Give the current page or opened Item one clear title. Supporting +metadata and controls stay quieter; hierarchy does not require inventing an editorial headline or +an unsupported intelligence claim. ## Layout -Use compact application chrome around the working content. Primary navigation stays easy to scan, -while management controls do not compete with reading. The first viewport must show useful content, -not oversized branding, statistics, or a decorative dashboard header. - -Wide containers support a list/detail composition, approximately one third navigation and two thirds -reading space where content permits. Narrow containers and mobile use a single active pane with a -clear Back action and restoration of selection, scroll, and focus. Choose the layout from the -Obsidian leaf's available space, not only the outer window width. - -Reader prose should have a comfortable measure around 60–70 characters. Use more space between -sections than between a title and its supporting summary, with a consistent underlying spacing -rhythm. Long titles wrap naturally without forcing sideways page scrolling or shrinking all text. -Content lists remain text-led; cover thumbnails do not belong in Feed, Search, or Digest lists. - -An optional summary is an inline disclosure belonging to its Item, not another floating card or -global panel. In Reader it precedes the original article; in lists/editions it stays within the -Item's reading rhythm. Distinguish it from the ordinary excerpt/body with a quiet label, whitespace -and a subtle seam. Preserve the scroll anchor/focus while pending text becomes ready. Exact -entry, generation, opt-in and saved-version behavior follow the backend's -[Item AI summary rules](docs/product/capabilities/item-ai-summaries.md). The inline controls here -describe the target UI; they are not yet available in the plugin. - -Background progress stays inside the existing model/Job context rather than becoming a monitoring -dashboard. Use a compact status line with a quiet phase label and N/M count; a thin, restrained -accent bar belongs to a known initial indexing cohort only. Preparation, catch-up and activation -remain distinct waiting states. Completion is not inferred from a visually full bar, and unknown -work must not acquire an invented overall percentage or ETA. Full history belongs in Task Center; -the Settings block returns to the ordinary active-model state after activation. - -Stop, Retry and Details form a small group of familiar host-native icons on that status line. -Avoid heavy button frames and permanently red destructive controls; use restrained hover/focus -feedback, visible keyboard focus, accessible names/tooltips and generous touch hit areas. Pending -is local and readable, not a decorative animation replacing the whole screen. Narrow layouts may -wrap status text while preserving clear action targets. These are target visual rules, not a claim -that progress controls or the separately planned recovery API are implemented. +The shell is a compact vertical workspace with `8px` outer padding, a `6px` shell gap, and a +rounded header above the active screen. Reading uses a list/detail split of `minmax(18em, 1fr)` to +`minmax(0, 2fr)` with a `6px` gap. The list and detail are independent rounded work areas, with +text-led rows and a generous detail reading pane. The widest reviewed Storybook leaf is 1100px +inside a 1180px viewport; the narrow reviewed leaves are 460px and 360px with no horizontal +overflow. + +At `48em` container width, the split becomes a single active pane: the selected detail replaces the +list and a Back action restores the reading context. The shell header tightens from `20px` to `16px` +radius, the brand wraps above primary navigation, and page padding becomes `24px 16px`. Primary +navigation wraps complete tab controls while keeping each label whole. Settings changes from a desktop +two-column layout (148px navigation, `36px` gap, editor section) to a mobile section picker, then one +full-width editor surface. Settings headings and resource controls wrap as groups, and the narrow +resource panel stretches to the available width, including the reviewed `460px` leaf. The compact +picker remains an explicit native `select`, not a hidden desktop navigation clone. Search follows the +same split and responsive restoration rules. + +Settings editor panes are right-anchored over their resource section at up to `560px`, with `28px` +padding and the same glass treatment as menus. On narrow leaves they become full width with +`24px 16px` padding and no edge shadow. The flow preserves the resource context while editing. + +The Feed list header reserves `32px` below the controls; the All/Unread filter uses `0 10px 8px` and +remains fully unobstructed. At rest the sticky header is an opaque panel. Once the list is scrolled, +only its header uses the existing glass token with `blur(12px)`; an opaque panel remains the fallback. +Refresh failures use an opaque panel status strip with `4px` top and `8px` bottom padding while the +loaded scroll position remains intact. Feed Topic selection is a searchable anchored picker; each +All Topics or Topic scope keeps its own selection and list position. The persisted All/Unread view +retains rows that became read during the current traversal, so a read action does not unexpectedly +erase the user's context. Search uses a quiet text field plus Search and Filters actions; Filters is +an anchored, nonmodal glass surface with explicit Apply and clear/close cancellation, UTC publication +dates, 20 results per request, and a 100-result session cap. + +Digest archive uses the same text-led rows and list/detail split: a definition select, Refresh, a +real Build menu, New badges, partial-issue metadata, and local empty/loading/recovery states. A +selected Issue becomes an Edition with a title/date lead, section index, topic sections, captured +summaries and overflow actions; selecting an Item replaces the list with compact Issue contents and +the captured Reader, with Back restoring the Issue and previous focus. Task Center follows the same +split at a `30`-Job page size with an explicit status filter, and keeps task facts and model context +next to shared Job progress and actions. The release layout evidence covers 46 dark/light captures +across desktop, narrow, mobile, and 200% text for the final loaded Shell screens; it reports no page +errors or overflow. Loading, empty, local failures, badges, menus, partial Issues, cancellation, and +failed/completed Job history are covered by separate prior feature matrices and interaction tests, +not counted among those 46 captures. + +Snapshot saves use a native `showModal()` dialog sized to `min(460px, calc(100vw - 32px))`, with a +`28px` inset, `22px` dialog corners, panel fallback, and the existing optional `blur(28px)` glass +treatment. Folder and filename fields are normalized before a save; Compact and Full are native +`16px` radios inside `44px` label targets, and Full-only Links, Remote, and Local image radios are +hidden when an individual Item has no full text. A mixed Full Digest shows the missing-Item count, +requires an inline native `16px` checkbox consent, and labels each omitted body `Excerpt only`; +individual missing-full Items explain the limitation and offer explicit Use Compact. Save remains the +peach primary action while validation, overwrite/write errors, and pending progress remain contextual +inside the form. Item and captured-Item overflow actions plus the whole-Issue `Issue actions` menu +open this same form; dismissal restores the originating trigger's focus. Compact includes ordinary +metadata/excerpts and matching ready AI summaries; Full preserves captured text and selected image +handling. The form does not request or wait for summaries and never updates a saved note after the +source changes. Final release evidence records 166 unit tests, 272 Storybook browser tests, the +mobile browser dependency-boundary check, nine native production flows plus the separately verified +SSE probe, and zero page errors or horizontal overflow in the 46-capture layout report. The fresh +finish review is `ship`; its one `460px` Settings panel correction is resolved. The detector record +has zero primary findings and one inherited native-dialog backdrop advisory. + +Spacing follows a small rhythm: `4px` and `6px` for adjacent controls, `8px` and `12px` for compact +insets, `16px` and `24px` for groups and sections, and `28px`/`32px` for page framing. Reader prose, +when present, stays at `70ch` maximum; content titles and long labels wrap naturally. Source code and +tables keep local horizontal scrolling and receive keyboard focus without introducing page overflow; +recognized fenced code is displayed as plain code and does not invoke host code-block processors. +Reader image slots remain matte, accessible placeholders until the user activates one; activation +loads all known images for that Item through a bounded four-download queue. Summary disclosure state +is local to the Item and exact source version. Closing the disclosure preserves its ready annotation +for reopening, and ready text remains readable when generation is disabled. ## Elevation & Depth -Surfaces are matte and flat at rest. Tonal differences, subtle seams, and whitespace establish -structure; avoid nested card borders and shadows around every row. Reserve elevation for genuine -overlays, respecting Obsidian's native menus and dialogs. No decorative gradients, glowing edges, -or ornamental glass effects. +Resting surfaces are matte and mostly flat. Panels separate through tone, rounded geometry, and +whitespace. Shadows belong to genuine overlays and the editor edge only. Action menus, Topic and +Search filter surfaces, feedback notes, and supported editor panes use the panel color before +optional glass; when `backdrop-filter` is available, those anchored surfaces switch to the theme +glass token and `blur(28px)` so the list context remains visible without turning the entire workspace +into glass. The Feed list header is a separate, lighter treatment: it stays opaque at rest and adopts +the same glass token with `blur(12px)` only when the list has scrolled. Dialogs stay opaque on their +panel background; only their native `rgb(0 0 0 / 42%)` backdrop dims the surrounding workspace. + +### Shadow Vocabulary + +- **Light overlay** (`0 16px 48px rgb(33 24 18 / 16%)`): action menus, feedback notes, and dialogs + in light mode. +- **Dark overlay** (`0 20px 56px rgb(0 0 0 / 38%)`): the same overlays in dark mode. +- **Editor edge** (`-16px 0 48px rgb(0 0 0 / 18%)`): desktop editor pane separation; mobile removes + this shadow. +- **Dialog backdrop** (`rgb(0 0 0 / 42%)`): the native dialog backdrop, distinct from elevation. + +**The Real Overlay Rule.** Use depth to clarify an anchored menu, note, dialog, or editor pane. Do +not add shadows around every reading row or turn a matte field into a floating card. ## Shapes -Use restrained, consistent corner treatment on controls and overlays. Content rows and article -sections should not become a collection of floating rounded cards. Exact radii remain unresolved -until implementation; native host controls keep their expected form and behavior. +Rounded geometry defines work zones, not every line of content. The wide shell header uses `20px` +corners and its narrow variant uses `16px`. List/detail panes and settings resource panels remain +`22px` at every leaf width; the desktop Settings navigation uses `18px`. Reading rows use `17px`. +Compact controls use `12px` or `13px`, menu items use `10px`, anchored menus use `15px`, and note +panels use `18px`. Chips use `8px`, textareas use `14px`, and checkboxes use `5px`. Borders are +absent on most controls; the settings/source list uses a quiet one-pixel line where a seam is +meaningful. + +## Components + +### Buttons + +- **Primary:** peach fill, selected-row ink, `12px` radius, `10px 18px` padding, and `42px` minimum + height. Hover uses the lighter peach. The compact Send note action retains its paper-plane icon + and uses `10px` padding. +- **Secondary:** matte field fill, primary ink, `12px` radius, `10px 16px` padding, and `40px` + minimum height. Hover uses the neutral hover field; disabled actions are `45%` opacity. +- **Icon:** icon-first controls use field fill, muted ink, `13px` radius, `40px` square dimensions, + and `10px` padding. A coarse pointer expands the square and minimum height to `44px`. Familiar + actions have an accessible name and native tooltip. +- **Focus and motion:** every focus-visible control gets a `2px` peach outline with `3px` offset. + Hover state transitions use `150ms ease-out` for background, color, and border. Reduced motion + sets scrolling to automatic and disables transitions and animations. + +### Cards / Containers + +List/detail panes, settings resource sections, and editor panes use panel backgrounds with `22px` +work-area corners; desktop Settings navigation uses `18px`. Feed cards are text-led and do not get a +decorative card border. A row shows its title, two-line excerpt, kind, and date; a selected row uses +peach fill, selected-row ink, `17px` corners, and `18px 16px` internal padding, while read-row titles +quiet to muted text. The persisted Unread view retains a row after it becomes read until the current +traversal is refreshed. Menu checkboxes additionally render a visible check mark; reading-row +selection itself is expressed by its peach fill and readable text contrast. Direct title activation +opens the reading pane and marks an unread Item read; the explicit Why this item details action opens +recommendation score, signals, pipeline version, and Item metadata without marking it read. + +### Inputs / Fields + +Inputs, textareas, and selects use a borderless matte field, `13px` corners, `12px 14px` padding, +and a `44px` minimum height. Settings form labels are `0.8125em`, muted, and sit above fields with +a `9px` gap. Native Snapshot radios and checkboxes remain `16px` controls inside `44px` label +targets, with the peach accent and no custom glyph replacement. The feedback note's label is `1em` +within its `14px` note-panel context; its textarea +uses `14px` padding and the host text-size fallback (`var(--font-text-size, 16px)`) with `1.6` +line-height. Checkbox marks use a `16px` control with `5px` corners and the peach accent. Focus uses +the shared `2px`/`3px` outline; placeholders use muted text at full opacity. + +### Navigation + +The shell header places the Topic Stream brand, three primary reading tabs, and quiet Tasks/Settings +icon slots in one compact row. The Digests tab can carry a compact peach New-count badge; the Tasks +slot opens Task Center without changing the primary reading-tab model. Primary tabs are transparent at +rest, use `13px` corners and `40px` minimum height, and switch to the ink-filled selected treatment; +when space is constrained, complete tab controls wrap while their labels remain whole. Settings uses a `148px` desktop +vertical navigation with `12px` corners; the current section uses a restrained peach wash and peach +text. Headings and resource actions wrap as groups. Below `48em`, the desktop nav is replaced by a +labelled native section picker, and the resource/editor surface stretches to the full available width. + +### Anchored Menus + +The ellipsis trigger opens a fixed, portalled `208px` menu anchored to the trigger. One overflow owns +Useful and Not for me as `menuitemcheckbox` choices, Why this item, Open original, Mark as read or +Mark as unread, Add to Topic, Wrong topic, automatic Topic matching, and Hide from all Feeds. Add note +or Edit note appears only after a rating and is separated from the choices. Arrow keys, Home/End, +Escape, Tab, outside pointer, scroll, and resize preserve the menu's focus and anchor semantics. +Menu hover/focus uses the theme's peach-soft wash. This is a real anchored operation, not a +permanently visible row of outlined buttons. + +### Feedback Note + +Choosing Useful or Not for me commits the rating immediately and opens a nonmodal, anchored `360px` +note panel beside that Item's trigger. The panel title reflects the sentiment and the field reads +“Optional note for future recommendations.” Only the paper-plane Send note action sends text. Close, +outside click, Escape, Tab exit, scroll, and resize preserve the rating; an unsent draft remains in +that Item's session and reopens through Add note/Edit note. Choosing Not for me in a Topic shows a +transient Wrong topic action in this same panel; it applies a Topic correction without replacing the +rating. Pending and error feedback stays local to the panel, canonical committed responses preserve +the saved intent while showing retrieval-update-pending status, and a successful note send closes it. +There is no permanent inline Add a note control and the note editor is not a modal dialog. + +### Search + +Search is a quiet, text-led collection surface: the `Search` field and `Filters` action sit above +rows that reuse the Feed's title, excerpt, metadata, and overflow treatment. The search field submits +explicitly; the anchored Filters surface is nonmodal, glass-backed when supported, and exposes Topic, +Source, UTC publication dates, Clear filters, and Apply filters. Requests load `20` Items at a time, +with a `100`-Item session cap and a local retry state for unavailable results or filter options. The +selected result opens the same reading/detail composition; an Item action notice remains near the +Reader title and is scoped to that Item. + +### Reader + +The canonical Reader puts the Item title first, with source headings subordinate inside host-native +reading prose. The reading body is bounded to `70ch`; `pre` and `table` regions are keyboard-focusable +and scroll locally, while recognized fenced code stays plain and cannot launch host code-block +processors. Images begin as matte, labelled placeholders with no thumbnail fetch. The first +activation consents to all known images for that Item, downloads at most four concurrently, and keeps +partial failures recoverable through a local Retry image action; object URLs are released when the +view closes. + +The compact, neutral `AI summary` button sits near the Reader title; list contexts use an overflow +entry where the Item supports it. Its accessible labelled disclosure is local to the Item and exact +source version: pending status appears before prose, ready text remains readable after opt-out, +generation is always explicit, and failed work offers Job Retry while status recovery is read-only. +Summary arrival preserves the current source, focus, and reading position; a source-version change +collapses the previous disclosure rather than showing stale text. + +### Digest archive, Edition, and captured Reader + +The Digest archive is a text-led collection surface with a definition select and one clear Build +action. The Build menu lists saved definitions, marks disabled definitions as “Manual only,” and +hands accepted work to the shared Job model; each definition keeps local requesting/queued/running, +uncertain, unavailable, and terminal history, with Check status when a Job ID is available. A successful Build exposes a +single Open built issue result; result lookup failures remain local and retryable. Archive rows show +Issue title, definition, generated date, partial status, and a peach New badge when the device-local +readiness model has not seen the Issue. The first successful unfiltered archive head establishes the +baseline once per device/backend/Workspace; later mount/manual REST reads reconstruct unopened +post-baseline Issues without catch-up Notices, while a live host Notice/event can add an Issue after +that baseline. Badges do not imply server-side reading state. + +The Edition keeps the Issue title and generated date primary, uses quiet section-index links and +topic headings, and preserves captured summaries or captured excerpts without inventing content. +Its compact contents pane replaces the archive while an Item is open; current contents use peach +selection, and Back restores the Edition's prior selection and focus. The captured Reader reuses the +canonical title-first Item card, explicit details, feedback, and exact-version Summary behavior. + +### Vault Snapshot form + +The Snapshot form is a single native modal reached from Item and captured Digest Item overflow menus +or the whole Issue `Issue actions` menu. It asks for a normalized folder and filename, then uses +native Compact/Full radios; image mode controls (Links, Remote, Local) appear only for Full content. +An individual Item without article body/README cannot save Full: the form explains why, hides image +controls, disables Save, and offers `Use Compact`. A mixed Full Digest shows its missing count and +requires explicit checkbox consent before saving; missing bodies are marked `Excerpt only` in order. +Ready AI text is included only when it matches the captured Item source version. The workflow is +portable and bounded: local attachments use the per-save file/size budgets, SVG bytes are sanitized, +existing paths are rejected before writes, and newly created attachments are rolled back on note +failure. Pending and errors stay local to the form, and Save does not generate, wait for, or later +mirror AI work. Closing or completing the form returns focus to the invoking overflow trigger. + +### Task Center and shared Job progress + +Task Center is the shared Job history: a status filter, 30-row pages, queue/status metadata, and a +detail view with authoritative status, attempts, timestamps, duration, reason, and model-transition +context. Active Jobs expose a familiar Cancel icon with a native confirmation dialog. Ordinary +dead/cancelled Jobs expose Retry; model-preparation Jobs additionally require a verified current, +eligible model target. Generated Digest Jobs can +open their saved Issue; a committed-result conflict is treated as a saved result, not a duplicate. + +Shared Job progress shows phase plus an initial-cohort count (`N / N` when known, or “total not known +yet”), never an invented percentage or ETA. Normal progress fills use the peach accent. Failed and +cancelled history use a muted fill. WebKit and Gecko use separate progress pseudo-elements, while the +status label remains authoritative: a full `N / N` bar does not imply Job success. Settings and Task +Center observe the same Job queries and host event port, with explicit Refresh recovery when an event +or REST read is unavailable. + +**The Status-Authority Rule.** Treat the Job status label as the outcome; the progress bar only +describes the observed initial cohort. Never infer success from a full bar or a count alone. ## Do's and Don'ts -- **Do** make the primary title, content titles, Topic labels, and metadata distinguishable before reading their words. -- **Do** keep familiar toolbar actions icon-first, with accessible names, tooltips, visible focus, and usable touch targets. -- **Do** keep explicit text for unfamiliar actions and for distinctions such as Hide versus Wrong topic. -- **Do** preserve the reading position and keep asynchronous feedback near the action that caused it. -- **Do** use brief, purposeful state transitions, respect reduced motion, and keep content visible without entrance animation. -- **Do** keep summary disclosures visually subordinate to the Item title, explicitly labeled and readable in both themes without replacing source content. -- **Do** give progress and icon-controls a compact, consistent rhythm without competing with reading titles; respect reduced motion and announce meaningful phase changes without per-counter screen-reader noise. -- **Don't** solve weak hierarchy by coloring every title, adding more badges, or boxing every content group. -- **Don't** introduce permanent administrative sidebars, KPI widgets, oversized branding, or a persistent global offline banner. -- **Don't** simulate blurred image previews when no thumbnail has been fetched, or fetch article images without consent. -- **Don't** claim that excerpts are AI summaries or that planned screens are already implemented. -- **Don't** expand other reading contexts or scroll the user to an AI result when background work completes. -- **Don't** turn initial-cohort completion into an overall success indicator, add an ETA without evidence, or expose operator tuning/benchmark widgets as routine product UI. -- **Don't** treat the exploratory mockups as pixel-bound assets or a reason to generate more images; the build path remains code-led. +### Do: + +- **Do** derive new surfaces from the black/charcoal work-area hierarchy and the light-mode pale + equivalent; keep fields matte and readable. +- **Do** reserve `#F2B38A` and `#F7C5A6` for action, selection, checked/pressed, hover, and focus + states; retain the light-theme accent ink where contrast requires it. +- **Do** preserve host typography, theme switching, keyboard focus, `44px` coarse-pointer targets, + and the `48em` leaf breakpoint. +- **Do** keep menus, filters, and note panels anchored to their trigger, with panel fallback colors + when blur is unavailable and `blur(28px)` only when supported. +- **Do** keep the Feed header opaque at rest and use the existing glass token with `blur(12px)` only + while its list is scrolled; preserve the opaque fallback and the `32px` header clearance. +- **Do** preserve the Reader's title-first hierarchy, `70ch` prose measure, local code/table scroll, + consent-gated images, and exact-version explicit summary disclosure. +- **Do** keep Search text-led with quiet Search + Filters controls, UTC date semantics, 20-result + pages, and a 100-result session limit. +- **Do** keep Digest archive and Edition surfaces text-led, preserve compact contents and captured + Reader return/focus, and show New Issue badges only for unseen device-scoped saved Issues. +- **Do** keep Task Center and Settings on shared Job queries; use peach normal progress, muted + failed/cancelled history, and explicit Cancel/Retry/model recovery actions. +- **Do** keep view-owned browser Fetch/ReadableStream SSE advisory: bind reconnect and pop-out + lifecycle to the active view, reconcile REST authoritatively, and retain loaded content plus explicit + Refresh when streaming is unavailable. +- **Do** keep Snapshot saves explicit and portable: normalize paths, reject existing destinations, + preserve captured content, honor image budgets and safe SVG handling, and restore focus to the + originating Item or Issue action after the native form closes. +- **Do** keep ready AI annotations source-version-bound and readable in Compact or Full without + generating, waiting, or mirroring later work; keep mixed Full consent and `Excerpt only` labels + visible when they apply. +- **Do** keep one clear action per view and use explicit text for unfamiliar distinctions. +- **Do** keep Storybook evidence connected to production components, mocking only external service + ports and using synthetic English/Spanish demonstration data. + +### Don't: + +- **Don't** invent a competing palette or direction, or treat the pinned reference as a source of + random generated comps. +- **Don't** add decorative gradients, glowing edges, ornamental glass, nested card borders, or + shadows around every row. +- **Don't** replace real anchored menus with a permanent inline Add a note control, a modal note + editor, or a row of outlined secondary actions. +- **Don't** claim physical mobile-device parity from responsive frames, browser dependency checks, or + desktop host tests. Browser Fetch/ReadableStream SSE and native desktop main/pop-out lifecycle are + shipped and verified, while physical iOS/Android streaming remains untested. +- **Don't** silently save an excerpt under Full, expose Full-only image controls when an individual + Item has no full text, overwrite an existing path, or imply that linked/failed images were stored + locally. +- **Don't** trigger Summary generation, waiting, Retry, or late note mutation from Snapshot Save; + capture only a ready annotation matching the saved Item input version. +- **Don't** infer Job success from `N / N`; preserve separate peach normal and muted failed/cancelled + progress history fills with the status label as the authority. +- **Don't** fetch thumbnails before image consent, turn a ready summary into a generated headline, or + display summary text from a different source version. +- **Don't** claim excerpts are generated summaries or add unsupported intelligence, KPI, ETA, or + operator-tuning UI. +- **Don't** use the exploratory raster as a pixel-bound asset; keep the implementation code-led and + preserve its provenance. diff --git a/PRODUCT.md b/PRODUCT.md index 6ebddae..fcdcba6 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -47,11 +47,13 @@ operations, and engineering references. The product name is Topic Stream. Product language is calm, direct, technically credible, and free of hype. The interface should help the user operate and understand the retrieval system without presenting unsupported intelligence claims. -The accepted visual direction is Signal Desk: a lightweight, precise reading workspace with -Readwise-inspired tonal restraint, a strong typographic hierarchy, cool neutrals, and a restrained -periwinkle-blue accent. Visual aesthetics are a core quality requirement, with equal care for -Obsidian light and dark modes. Yellow and ochre are not brand accents. The target direction is -recorded in [DESIGN.md](DESIGN.md); its pre-implementation seed does not claim the redesign is shipped. +The accepted visual direction uses the user's pinned reference: black and charcoal work areas, +matte fields, clear typography, and a soft peach accent (#F2B38A, hover #F7C5A6). Translucent, +blurred editor panels retain the surrounding list context; secondary operations belong in real +anchored menus. This replaces the former Signal Desk palette. Visual aesthetics are a core +quality requirement, with equal care for Obsidian light and dark modes, keyboard use and narrow +leaves. [DESIGN.md](DESIGN.md) records the implemented system. Later feature surfaces inherit +this direction while retaining their own delivery status. ## Evidence on Hand diff --git a/README.md b/README.md index 47bc345..ce3ee78 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,18 @@ A personal reading stream inside Obsidian, shaped by the sources and topics you Topic Stream brings articles, papers, videos, and repositories into one place. Describe what you want to follow, read a focused Feed, and use feedback to improve future recommendations. -The server runs on your own machine. The current Obsidian plugin provides an early Feed view; -the backend exposes the broader reading and retrieval capabilities through its API. +Run the backend on your own machine or a reachable server, then connect the Obsidian plugin. ## What you can do -In the current plugin you can create Topics, request Sync all, browse already-published Feed -history, open original links, and give Like/Dislike feedback with optional comments. Its Source -form still sends the old request shape; create Sources through the backend API using the guide. -Its **Refresh** button rereads saved history. To publish recommendations, use the backend -[Feed Refresh API](docs/guides/getting-started.md#get-your-first-feed). +- Manage Sources, Topics, preferences, models and Digest definitions in Settings. +- Read the Feed by Topic, keep an Unread view, and publish the next batch with explicit Refresh. +- Search the archive, read cleaned Markdown, load images by consent, and request an AI summary explicitly. +- Read saved Digest editions, build Manual-only or scheduled definitions, and follow Tasks with safe Cancel/Retry. +- Save independent Compact or Full Vault Snapshots, including bounded local attachments. -The backend also supports arXiv, GitHub Trending and YouTube Sources, Topic corrections, archive -Search, cleaned content and image delivery, explicit Item AI summaries, and scheduled Digests. -Search, Reader, Digest, Task Center and AI-summary controls are not yet available in the plugin. -The plugin does not yet write Vault Snapshots. See the -[current Obsidian capabilities](docs/product/capabilities/obsidian-feed.md). +Live advisories reconcile saved state without publishing Feed or generating AI text. The +[plugin guide](docs/guides/using-topic-stream.md) describes each workflow. ## Get started @@ -46,11 +42,52 @@ AI models are managed separately. 4. [Build and install the Obsidian plugin](docs/guides/getting-started.md#install-the-obsidian-plugin), connect to `http://127.0.0.1:7331`, and follow the - [first Feed steps](docs/guides/getting-started.md#get-your-first-feed), including API publication. + [first Feed steps](docs/guides/getting-started.md#get-your-first-feed). The [getting started guide](docs/guides/getting-started.md) walks through configuration, model setup, and your first Feed. The default deployment exposes the API only on localhost; it has no built-in authentication. +## Install the plugin manually + +Use the three files from a plugin release, or build this checkout: + +```bash +pnpm install --frozen-lockfile +pnpm --dir plugin build +``` + +Copy `plugin/main.js`, `plugin/manifest.json` and `plugin/styles.css` to +`/.obsidian/plugins/topic-stream/`, then enable Topic Stream in Community plugins. +Obsidian 1.8.7 or newer is required. Releases use independent plugin versions and the MIT license; +Community Plugins directory submission is deferred. + +For mobile, `127.0.0.1` means the phone or tablet. Set Server URL to a backend reachable from that +device through your configured LAN, VPN or protected HTTPS deployment. The default localhost-only +Docker binding must be configured for that access. Streaming also needs the backend's +[exact-origin CORS allowlist](docs/operations/configuration.md#browser-access-and-sse); the default +is `app://obsidian.md`. Browser-safe dependencies and responsive frames are checked; physical +iOS/Android smoke has not been performed. + +## Development + +Use a disposable development vault. Run `pnpm --dir plugin dev` and copy the three plugin files +there; reload the plugin after code changes and restart Obsidian after manifest changes. +`pnpm --dir plugin storybook` opens the connected production Shell with mocked external services +at port 6006. `pnpm --dir plugin check` covers generated types, lint, the mobile dependency graph +and unit tests. Run `pnpm --dir plugin test:storybook` for browser interactions and +`pnpm --dir plugin test:e2e:headless` for real Obsidian in Docker/Xvfb; `test:e2e` runs it with a window. +See [development](docs/development.md) and [release workflows](docs/releases.md) for details. + +## Network and privacy + +The plugin sends reading actions, settings and explicit requests to your configured backend via +REST and receives advisory SSE. The backend fetches configured Sources and uses the embedding/AI +providers you configure; optional AI generation is opt-in and may incur that provider's costs. +Reader images stay unloaded until you choose to load them. Save to vault writes only the requested +note and optional attachments through Obsidian; Remote-image snapshots retain network image URLs. +Opening an original link uses the system browser. There is no telemetry, plugin account or bundled +commercial service. Keep API access behind your chosen network/authentication boundary. + ## Learn more - [Use Topic Stream](docs/guides/using-topic-stream.md) — the current plugin workflow and backend API references. diff --git a/devctl.yaml b/devctl.yaml index a94eef4..af7856e 100644 --- a/devctl.yaml +++ b/devctl.yaml @@ -51,7 +51,9 @@ components: server: openapi: api/openapi/openapi.yaml start: {env: HTTP_SERVER_ENABLED, default: true} - env: {} + env: + custom: + - {key: CORS_ALLOWED_ORIGINS, type: string, default: "app://obsidian.md"} db: connections: - name: primary diff --git a/docs/adr/0002-contract-first-rest-with-advisory-sse.md b/docs/adr/0002-contract-first-rest-with-advisory-sse.md index fd65749..163c999 100644 --- a/docs/adr/0002-contract-first-rest-with-advisory-sse.md +++ b/docs/adr/0002-contract-first-rest-with-advisory-sse.md @@ -18,3 +18,11 @@ Transport handlers always delegate operation availability to a service. Operatio Every request passes through `oapivalidator` using the OpenAPI document embedded in the generated package before it reaches a handler. Its normalized failures and generated binder errors are mapped to the public `Problem` schema. Contract-invalid input remains a `400 Bad Request`, while a missing required `If-Match` precondition remains `428 Precondition Required`, preserving the API's established behavior. The strict adapter uses an Echo JSON serializer that preserves the former controllers' unknown-field and single-value checks on the body supplied by validation. Decode failures retain operation-specific problem types, and conditional updates retain precondition-error precedence. OpenAPI validation may apply schema defaults and re-encode a body before binding; this migration preserves that existing behavior. + +The HTTP API uses Echo's built-in CORS middleware with one operator-managed exact-origin allowlist, +covering product and admin routes. The default permits `app://obsidian.md`; explicitly empty +configuration disables CORS. This shared HTTP policy replaces an SSE-only exception and enables +browser streaming because Obsidian's CORS-independent requestUrl API buffers response bodies. +CORS runs before OpenAPI validation so standard preflight requests are answered by the middleware. +Credentials are disabled; optimistic-concurrency and idempotency request headers are allowed, +and ETag is exposed. CORS is browser response-read permission, not authentication. diff --git a/docs/development.md b/docs/development.md index f669ea9..2075a12 100644 --- a/docs/development.md +++ b/docs/development.md @@ -54,6 +54,77 @@ pnpm check Install the output using the [Obsidian setup instructions](guides/getting-started.md#install-the-obsidian-plugin). For incremental builds, use `pnpm --dir plugin dev`. +The plugin generates TypeScript operation/schema types from the authoritative OpenAPI document. +After a contract edit run `pnpm --dir plugin api:generate`; `api:check` compares the generated +bytes without editing files and runs in both plugin and contract CI. Endpoint methods remain +handwritten over the buffered Obsidian `requestUrl` adapter. The generator and lint parser use +TypeScript 5.9.3's compiler API; Obsidian typings target the supported minimum host version 1.8.7. + +Run individual gates with `pnpm --dir plugin typecheck`, `lint`, `test`, or `build`; `check` +combines generated drift, typecheck, lint, the browser/mobile dependency build and tests. +Production builds also typecheck and minify. Lint covers Obsidian guidelines, React Hooks, the +configured complexity ceilings and desktop imports/globals. `test:mobile` bundles the actual +production entry for browsers, allowing only the public Obsidian host API as an external. + +Production-bundle lifecycle tests use a copied disposable vault: + +```bash +pnpm --dir plugin test:e2e:headless +# Optional native desktop run with an application window: +pnpm --dir plugin test:e2e +``` + +The headless runner uses Docker/Xvfb with pinned `mcr.microsoft.com/playwright:v1.64.1-noble`, +a read-only source mount and dedicated dependency/Obsidian-cache volumes. It installs the +lockfile in Linux and builds the plugin there. Host logs persist in `.artifacts/obsidian/logs/`; +CI runs this same headless command and uploads those diagnostics on failure. +Set `OBSIDIAN_APP_VERSION` (default `1.8.7`), +`OBSIDIAN_INSTALLER_VERSION` (default `earliest`), or comma-separated `OBSIDIAN_TEST_VERSIONS` +to select hosts. Tests use local API fixtures and never install into a personal vault. The +host flows verify URL replacement, preference persistence, disable/re-enable cleanup, independent +All/Topic visits, Unread retention through reading and Refresh, and a fresh visit after view reopen. +Reader coverage uses the real host MarkdownRenderer, verifies no vault files are created, blocks +images until consent, reuses them during the view session, blocks source-triggered code processors +and checks explicit-only Summary generation. Digest/Task flows exercise saved editions and Build; +Snapshot flows verify real Vault/FileManager writes, overwrite rejection, rollback, preferences +and captured text. SSE flows cover two views, dropped/malformed advisories, REST fallback, +pop-out migration, endpoint replacement and unload. Fixtures supply external API data; the +optional production Echo probe supplies separate real backend CORS/streaming evidence. +See [the plugin quality record](plugin-quality.md) for the evidence boundary and release checks. + +## Storybook + +Start the component catalog with `pnpm --dir plugin storybook` and open +`http://127.0.0.1:6006/`. `pnpm --dir plugin build:storybook` produces the ignored static catalog; +`pnpm --dir plugin test:storybook` runs its play/accessibility/geometry tests in headless Chromium. +Install that browser once with `pnpm --dir plugin browser:install`; its cache stays under +`.artifacts/playwright`. CI installs the pinned browser and runs story tests and static build. + +Organization follows the other Obsidian plugins: co-located `*.stories.tsx` next to components, +`Features///` for product surfaces and `Shared//` +for reusable components. PascalCase story names describe states such as Loaded, Loading, +LoadError and FeedbackPending. The default toolbar selects light/dark and leaf/narrow/mobile +frames. Shared typed fixtures, deterministic service ports, providers and host frames live in +`plugin/src/test/storybook/`; `.storybook` contains discovery, addons, preview and the curated +Obsidian CSS fixture. Test-only compositions are clearly labeled under Shared/Testing; +regression-only stories stay in browser tests while hidden from normal navigation/autodocs. +The main entry is `Features/Shell/TopicStreamShell`. It renders the production connected shell +and currently implemented screens with in-memory service implementations. It makes no live API +calls. Technical connection/error/geometry scenarios run in browser tests and stay hidden from +the ordinary catalog. Each new feature extends this same composition. + +Feed owns optional consumer ports for Reader content, shared Item summary state and Vault saving. +Hidden contract scenarios exercise invocation and presentation through those ports. The plugin +and primary Shell now connect the production Reader, shared Summary and Snapshot providers; +Storybook mocks only external backend and host ports. Feed event reconciliation consumes a +separate advisory event port. Each ItemView injects a browser Fetch/ReadableStream adapter and +shares one cancellable connection among feature subscribers. Pop-out migration reopens that +stream using the new owning window without resetting navigation or cached reading state. +Retries use bounded exponential backoff (1–30 seconds with jitter), never REST polling. +Connected and typed events reconcile authoritative resources; they never publish Feed or +generate summaries. See [CORS configuration](operations/configuration.md#browser-access-and-sse) +and the [transport decision](adr/0002-contract-first-rest-with-advisory-sse.md). + ## Repository map | Location | Responsibility | @@ -221,3 +292,12 @@ collection scenario. Ensure covers missing indexes and persisted legacy incarnat concurrent creators, pre-limit filters and cross-Workspace isolation remain separate real-server oracles. Assembled HTTP/worker tests retain their transport and recovery guarantees even when a focused adapter or mocked unit test covers one individual operation. + +Snapshot coverage exercises fixed Item/Digest Markdown, safely quoted frontmatter, exact-version +annotations, Compact/Full consent and image modes. Local attachment budgets are shared per Save; +image downloads accept only Item descriptors, and DOMPurify uses a static SVG allowlist. The +Obsidian adapter serializes writes per Vault and uses FileManager rollback for only newly-created +attachments. Native headless tests create real isolated notes, parse host frontmatter, reject +overwrite, inject note-create failure to verify rollback, retain preferences across reload and +save a confirmed mixed historical edition. Snapshot UI and menus are part of the primary +production-composed Shell story; only Vault/preferences and backend ports are in-memory. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 34ed357..b791dfe 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -1,7 +1,8 @@ # Getting started This guide runs the server on your computer and connects the current early Obsidian plugin to it. -The plugin reads saved Feed history; publishing recommendations currently requires an API request. +The plugin publishes ready recommendations on first Feed visits and explicit Refresh, then keeps +older saved history available while you read. ## Before you start @@ -92,13 +93,15 @@ Create `.obsidian/plugins/topic-stream/` inside your vault and copy these three - `plugin/styles.css` In Obsidian's Community plugins settings, enable **Topic Stream**. Open its settings and keep -**Server URL** at `http://127.0.0.1:7331`. Open the view through the ribbon icon or the +**Server URL** at `http://127.0.0.1:7331`, or enter a different device-local address. +**Test connection** checks the draft without applying it. **Apply** replaces the address in +open views. The plugin requires Obsidian 1.8.7 or later. Open the view through the ribbon icon or the **Topic Stream: Open feed** command. ## Get your first Feed -1. Create an RSS/Atom Source through the API. The current plugin's Source form sends the old - `kind`/`url` shape; the server requires `config`. Replace the example URL with your feed: +1. Open the plugin’s Settings → Sources and create an RSS/Atom Source with **Add source**, or use the API's typed `config` + below. Replace the example URL with your feed: ```bash curl --fail -X POST http://127.0.0.1:7331/api/v1/sources \ @@ -109,21 +112,20 @@ In Obsidian's Community plugins settings, enable **Topic Stream**. Open its sett Use `"kind":"atom"` in `config` for an Atom Source. 2. Create a Topic with a short Include description, such as “Practical distributed systems”. Leave the Source scope empty to consider all Sources; add Exclude criteria only when needed. -3. Choose **Sync all now** and allow ingestion, indexing, and Topic preparation to finish. -4. Publish eligible recommendations through the backend API: - - ```bash - curl --fail -X POST http://127.0.0.1:7331/api/v1/feed/refresh \ - -H 'Content-Type: application/json' \ - --data '{}' - ``` - - This uses ready candidates without waiting for preparation. If preparation is still running, - wait and deliberately repeat the request; short or empty results are valid. Do not configure - automatic retries for this publication request. -5. Choose **Refresh** in the plugin to read the latest saved Feed. This button only performs GET; - it does not publish recommendations. Load older pages, open an Item's original link, and leave - Like/Dislike feedback when useful. Topic correction controls are not yet available in the plugin. +3. Plugin creation requests the first Sync separately after saving the Source. If you created it + through the API, use **Sync now** or **Sync all** in Settings. Allow ingestion, indexing and Topic + preparation to finish; a failed Sync keeps the Source and offers a deliberate retry. +4. Return to **Feed** and choose **Refresh**. The first visit to All Topics or an enabled Topic + also checks for recommendations once. These requests use ready candidates without waiting for + preparation. Short or empty results are valid; wait and deliberately Refresh again when needed. + Failed requests keep saved history and do not retry publication automatically. +5. Use the Topic switcher and **All / Unread**, load older pages, and open an Item title to read its + canonical Markdown in Reader. Images load only after you activate a placeholder. Its **…** menu + provides feedback, the original link, Reading State, Topic corrections and global Hide. Search + browses the all-time archive; optional **AI summary** always requires an explicit action. +6. Open **Digests** to read saved editions or **Build** from a saved Digest Definition, including + Manual only ones. Use **Tasks** for background status and explicit Refresh, Cancel or eligible + Retry. Opening an entire edition does not mark its Items read. Continue with [Using Topic Stream](using-topic-stream.md) for the reading workflow. @@ -139,3 +141,8 @@ them. Use the [backup guide](../operations/backup-and-restore.md) before intenti For another device, `localhost` refers to that device. Configure access through a trusted private network using the [connection guidance](../operations/configuration.md#connecting-from-another-device). + +Use **Save to vault** in an Item or whole-Digest menu to create an independent Markdown snapshot. +Choose its destination and content/image modes explicitly; existing files are never replaced. +See [Using Topic Stream](using-topic-stream.md#save-an-independent-vault-snapshot) for image limits +and mixed Full Digest consent. diff --git a/docs/guides/using-topic-stream.md b/docs/guides/using-topic-stream.md index e70b1c1..c19e032 100644 --- a/docs/guides/using-topic-stream.md +++ b/docs/guides/using-topic-stream.md @@ -1,58 +1,246 @@ # Using Topic Stream -The current Obsidian plugin provides a Feed view, Topic creation, Sync all, original links and -Like/Dislike feedback. Its Source form uses an outdated request shape, so create Sources through -the API. This guide describes the available workflow. -The backend's other capabilities are accessible through its API. +The Obsidian plugin provides Feed reading, saved Digest editions, archive Search, Reader, Tasks +and a Settings workspace for Sources, Topics, Digest Definitions, Workspace Settings and a global +Preference Profile. -## Choose Sources and Topics +In Obsidian’s plugin settings, edit **Server URL** and choose **Test connection** to check the draft +address, or **Apply** to replace open connections. Typing or testing does not apply the address. +The URL stays local to this device. Default All/Unread and Snapshot content/image preferences are +saved with the vault; each Save to vault still asks for its destination and content mode. -Create an RSS/Atom Source using the [API example](getting-started.md#get-your-first-feed), then -add a Topic in the plugin with a name, optional Include and -Exclude descriptions, Source scope, Item kinds and ordinary or strict matching mode. An empty -scope considers all Sources or kinds. The plugin does not provide the full resource-management -interface. +## Manage Sources and Topics -Choose **Sync all now** to enqueue a synchronization request for enabled Sources. The view shows -the returned Job ID; fetching, indexing and Topic preparation happen in the background. Individual -Source synchronization and Job inspection are available through the API. +Open the **Settings** icon, then **Sources**. **Add source** supports RSS/Atom feed URLs, arXiv +queries, YouTube Channel IDs and GitHub Trending period/language. YouTube requires an ID rather +than a channel URL. Poll interval, enabled state and RSS/Atom private-network policy are in +**Advanced**. Private-network access starts disabled. -## Read saved Feed history +Creating a Source saves it, then requests its first Sync separately. If Sync fails, the Source +stays saved; **Retry sync** repeats Sync rather than creation. Each row also offers Edit, +Pause/Enable, confirmed Delete and Sync now. **Sync all** requests synchronization of enabled +Sources without claiming individual results. Rows show the last saved synchronization timestamp. +Current-view Job acknowledgements and failures are session-local; after reopening, use Sync now +or inspect Tasks rather than assuming a previous per-Source outcome. -Follow the [first Feed steps](getting-started.md#get-your-first-feed) to publish recommendations -through `POST /api/v1/feed/refresh`. Publication adds eligible Items above saved history and -preserves the order of older cards. Preparation may still be running when a request returns. +In **Topics**, create or edit Name, Include and Sources. **Advanced** contains Exclude, Item kinds, +ordinary/strict matching and enabled state. Empty Source or Item-kind scope is unrestricted. +**Choose sources** offers search, checkboxes and **All sources**. **Add source** inside the picker +opens a separate form and returns to the intact Topic draft. The new Source belongs to the whole +Workspace; it joins an explicitly limited scope while All sources stays unrestricted. Topics also +support Pause/Enable, revision display and confirmed Delete. -The plugin's **Refresh** button rereads that saved history through GET. It cannot publish a new -Batch. The view loads older pages as you scroll and currently has no Topic Feed selector. -Opening an Item title follows its original URL; it does not open an in-plugin Reader or record -Reading State through the backend. +Leaving a dirty editor offers **Save**, **Discard** or **Stay**. Save proceeds only after validation +and persistence succeed. Versioned resources keep drafts on a conflict and offer **Review latest**, +**Keep my draft** or **Use saved values**; keeping a draft requires another explicit Save. + +## Configure Digest Definitions + +Under **Digest definitions**, choose ordered Topics and per-Topic Item limits. New Definitions +start with **Manual only**. **Scheduled** reveals recurrence, time and IANA timezone. Switching to +Manual only stops future scheduled runs and keeps the calendar values for later use; it does not +disable manual Build. New calendars start at Monday 12:00 in the local timezone. + +**Build** requests a saved Definition explicitly. Saving or changing delivery mode never starts a +Build. If the response is uncertain, **Retry request** checks the same stored request identity, +including after reopening. A queued Build remains inspectable through its Job; completion does not +replace the current reading screen. Deleting a Definition stops its schedule and retains saved +Digest Issues. + +## Read saved Digest Issues + +Open **Digests** for the newest saved Issues, optionally filtered by their saved Definition. +**Load older issues** continues the archive. **Build** offers saved Definitions, including Manual +only ones; **Manage definitions** is secondary in the archive’s **…** menu. Build status and a +link to its exact task remain local. A completed Build offers **Open built issue**; completion +does not replace the edition or Item you are reading. + +Opening an Issue shows the whole saved edition with its Topic sections and ordinary excerpts. +It does not mark its Items read. Opening an Item title shows its captured content in Reader and +marks that Item read; wide leaves replace the archive with compact Issue contents. **Back to +issue** restores the edition’s position and title focus, and **Back to archive** returns to the +Issue list. A partial Issue retains its readable sections and explains the incomplete ones. + +The Item menu includes **Why this item**, feedback and explicit **AI summary** where the server +allows it. Summary remains separate from the ordinary excerpt and is bound to that saved text +version. Expanding it does not open Reader, mark the Item read or rebuild the Issue. A failed +summary retries its eligible task; a later result fills that Item’s disclosure without changing +the saved edition’s facts or status. + +Existing history is baselined once per device, backend and Workspace. On reopening or **Refresh**, +later unopened Issues receive **New** badges without catch-up Notices. Opening that exact Issue +clears its badge on this device. Live notifications are not connected yet; use Refresh to reconcile +an already open view. + +## Inspect background Tasks + +Open **Tasks** to inspect Fetch, Generation, Retrieval, Feed Preparation and Maintenance work. +Filter by status or load older tasks. Details show attempts, reason, recorded timestamps and +duration, with optional fixed-cohort progress. Equal counts do not imply activation; task status +is authoritative, and unknown totals never invent an ETA. + +**Refresh tasks** and **Refresh selected task** read current status without polling. A failed +read preserves the last loaded details. Cancel requires confirmation and remains a request +until the server records a terminal outcome. Retry opens the server’s actual successor task, +which may already be complete. Historical model preparation offers Retry only with a verified +current public target. A Build Retry conflict checks for the Issue already saved by that task +and offers **Open saved issue** instead of submitting another Build. + +## Configure the Workspace + +**Workspace** separates Embedding Models from AI generation. Select an available, initialized +Embedding Model, then save explicitly. Desired and Serving remain distinct during preparation; +replacing active preparation requires confirmation. The previous Serving model stays available +until activation. Without a Serving model, saved Feed history, Settings and Jobs remain usable. + +Progress uses the public Job phase and fixed initial Item count. A full bar does not mean +activation; unknown totals have no invented percentage or ETA. Refresh reads authoritative status. +Cancel preserves Desired and any previous Serving model; Retry is offered only for the verified +current operation. Current user-initiated outcomes are remembered locally and produce at most one +Notice after final failure or confirmed activation. Live Job advisories refresh authoritative +status while the view is open; progress itself never declares activation or failure. + +AI generation is opt-in. Configure the operator-provided endpoint and model names, summary language +and limits; credentials remain server-managed. Saving never generates or regenerates text. +Instructions, retention and retrieval tuning are in Advanced. **AI summary** in Reader or an Item's +menu requests an annotation explicitly; opening an Item never starts generation. + +**Preferences** edits Workspace-wide Prefer/Avoid guidance for optional Digest AI reranking. +Topic Include/Exclude and Feed feedback are separate; this profile does not introduce another +Feed or Search ranking effect. **Status** shows backend/component availability independently of +optional AI configuration. + +## Read the Feed + +Choose **All Topics** or search for an enabled Topic in the compact switcher beside Feed. +Each selection checks for ready recommendations once on its first visit in an opened view. +**Refresh** checks again deliberately. Preparation may still be running when it returns; wait +and choose Refresh again when needed. A failed attempt keeps saved content and never retries +publication automatically. Closing and reopening the view starts a new first-visit lifecycle. + +Older pages load as you scroll within the saved traversal. Switching Topics restores each +selection’s loaded pages, selected Item and scroll position. Refresh with new publications +returns to the new top; membership-only changes keep the reading position. Failed Refresh reports +beside the button and preserves the loaded traversal. If an older page fails, **Try again** +continues the same history without replacing it. + +**All / Unread** is saved with the vault. It filters loaded pages without requesting a new Feed. +An Item marked read stays dimmed in the current Unread traversal until successful Refresh. +**Load older items** remains available when the loaded pages contain no unread Items but older +history exists. **No unread items** means the loaded traversal is exhausted. + +Open an Item title to show its reading pane and mark it read. Wide leaves retain the list; +narrow leaves use **Back to list**, preserving position and focus. **Open original** in **…** +opens the source and marks it read. **Why this item** opens full explanation, score and metadata +without changing Reading State. **Mark as read / Mark as unread** is explicit in the same menu. +Reader displays canonical Markdown inside the view without creating a vault file. Full-content +failure keeps the title, excerpt, metadata and navigation available. **Check for full content** +rereads an empty Item; rendering failures offer **Try again**. Abstracts and descriptions are +labeled honestly rather than presented as a full extracted article. + +Images are withheld until you activate a placeholder. The first activation loads all known images +for that Item. Another Item requires its own consent; returning to an Item reuses loaded images +within the opened view. Failed images offer their own **Retry image** without interrupting reading. + +## Search and summarize + +Open **Search** to browse saved Items across all time, including Hidden Items. Submit a query for +hybrid search, or submit an empty query to browse the archive. **Filters** opens an anchored panel +for Topic, Source and UTC publication dates. Apply explicitly; closing the panel leaves the current +session unchanged. Search loads 20 results at a time, up to 100 per session. Refine the query or +filters to start another session. A temporary retrieval outage offers recovery and links to +Settings/Tasks while saved content remains usable. + +Title and **Why this item** actions follow the same reading rules as Feed. Feedback, corrections, +Hide and Reading State remain shared; a Hidden Item stays discoverable in Search. Back restores +the result position and focus. + +Choose **AI summary** near the Reader title, or in an Item's **…** menu. Generation must be enabled +and the server must have supported article/README text. Unsupported input does not offer a new +generation action. An existing ready annotation remains readable after generation is disabled. +The pending block opens immediately without replacing the source. Duplicate clicks reuse work; +**Check summary status** reads its current state. **Retry AI summary** retries an eligible failed +summary Job. Status-read errors have a separate read-only recovery control. Until live advisories +are connected, use the explicit status check for completion. + +Summary disclosure alone does not mark an Item read. Completion preserves focus and reading +position, and does not expand other Item contexts. A changed text version requires its own +disclosure; an old annotation is never shown for new source text. ## Give feedback -Use **Useful** or **Not for me** to save Like or Dislike feedback. An optional comment accompanies -that decision. Feedback is your current choice; another choice replaces it. A Dislike affects -future recommendations and does not hide an already-published card. +Choose **Useful** or **Not for me** from the Item's **…** menu. The rating saves immediately and a +small blurred note panel opens. A note is optional: use the paper-plane **Send note** button to send +it, or close the panel to keep only the rating. Unsent drafts survive closing/reopening the panel +for that opened Item and failed sends. Use **Add note** / **Edit note** in the same menu to return. +The row and reading pane share the rating and draft. **Not for me** also offers **Wrong topic** in +the note panel. Feedback is the current choice, so another choice replaces it. Dislike affects future recommendations and does not hide an +already-published card. Personalization begins using each sentiment independently after five +current decisions of that kind. Deterministic scoring ignores comments. + +Use **Add to Topic…** to include an Item, **Wrong topic** to exclude it from the selected Topic, +or **Use automatic matching** to clear the manual correction. In All Topics, the correction menu +asks which Topic to change. Corrections keep the original publication position and do not publish +the Item again. **Hide from all Feeds** hides it globally, separately from a Topic correction. +If feedback or a correction reports **Saved. Retrieval update pending**, the write is retained; +do not submit it again just to finish retrieval. Refresh can read its current state. -Personalization begins using Like and Dislike evidence independently after five current decisions -of each kind. Deterministic scoring ignores comments. Topic corrections, Reading State and -Hide/Unhide are separate backend capabilities whose controls are not yet available in the plugin. +## Save an independent vault snapshot + +Choose **Save to vault** from an Item’s **…** menu in Feed, Search, Reader or a saved Digest. +The whole-edition **…** menu beside a Digest title saves the entire Issue. Save does not mark +Items read. Every attempt asks for **Folder** and **Filename**; leaving Folder empty uses the +vault root. Paths are normalized, `.md` is added, and an existing destination is rejected. +Choose another filename to keep both notes. + +**Compact** keeps ordinary excerpts, source links, metadata and any ready AI summary bound to +that exact text version. **Full** keeps the available article body or README. An individual +abstract/description-only Item explains why Full is unavailable and offers **Use Compact**. +A mixed Full Digest requires explicit consent to keep missing bodies as **Excerpt only**; +choose Compact instead to use excerpts throughout. Digest order, captured text and matching +captured annotations are preserved even when the canonical Item has changed. + +Full offers three image modes: + +- **Links** creates ordinary clickable source-image links without downloading images. +- **Remote** keeps external image embeds; opening the saved note may load their original sites. +- **Local** downloads permitted Item descriptors into `.assets/` and uses relative + embeds. Each Save allows 30 files, 10 MB each and 100 MB total across the entire Digest. + +Unavailable, unsafe or over-budget Local images remain ordinary links, and the result reports +how many stayed links. Historical descriptors never trigger an arbitrary-URL download or +substitution with a newer canonical image. SVG is sanitized before attachment creation. +The last content and image choices are remembered with this vault. + +Save includes only ready matching annotations available for that attempt, including stored text +after generation is disabled. It never generates, retries or waits for AI work. Notes remain +independent: later summaries, backend changes and plugin reloads do not update saved files. +A note-write failure removes only attachments created by that attempt; any cleanup failure names +the files that remain. Cancel or Escape before submission leaves the vault unchanged and restores +focus. While a write is in progress the form waits for its result. ## Use the backend API -The plugin currently has no Search, Reader, Digest, Task Center, AI-summary or Vault Snapshot -controls. Use these references for the implemented server behavior and its HTTP contract: +An open Topic Stream view receives advisory Feed, Digest and Job events. Reconnection reads +authoritative REST state; it does not replay events, publish a new Feed or generate AI text. +An unavailable stream keeps saved content readable and explicit Refresh available, with no +polling or global offline banner. Offline Digest catch-up adds badges; live readiness produces +at most one Notice per device, backend and Workspace, including across open views. + +Streaming uses browser Fetch and requires the backend's exact-origin CORS policy. The default +allows `app://obsidian.md`; see [operator configuration](../operations/configuration.md#browser-access-and-sse). +Desktop Obsidian main/pop-out streaming is verified. The adapter uses browser APIs without +Node/Electron dependencies; physical iOS/Android streaming has not been smoke-tested. + +Server capabilities and contracts are documented separately: - [Search and content delivery](../product/capabilities/search-reader-and-events.md). - [Explicit asynchronous Item AI summaries](../product/capabilities/item-ai-summaries.md). -- [Digest Definitions, schedules and immutable Issue history](../product/capabilities/digests-and-schedules.md). -- [Jobs, cancellation, Retry and indexing progress](../product/capabilities/durable-jobs.md). -- [Workspace Settings and model selection](../product/capabilities/workspace-settings.md). -- [OpenAPI paths, request schemas and responses](../../api/openapi/openapi.yaml). - -The backend supplies content and validated image downloads for clients; the current plugin does -not create Markdown files in the vault. The accepted -[Vault Snapshot boundary](../product/decisions/0004-explicit-vault-snapshots.md) describes that -explicit client-owned operation. +- [Digest schedules and immutable Issue history](../product/capabilities/digests-and-schedules.md). +- [Jobs, Retry and indexing progress](../product/capabilities/durable-jobs.md). +- [Workspace Settings and models](../product/capabilities/workspace-settings.md). +- [OpenAPI contract](../../api/openapi/openapi.yaml). -For connection or recommendation problems, see [troubleshooting](../operations/troubleshooting.md). +The [Vault Snapshot boundary](../product/decisions/0004-explicit-vault-snapshots.md) keeps +explicit client-owned notes separate from backend content. See [troubleshooting](../operations/troubleshooting.md) for +connection or recommendation problems. diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index df24e0f..d7aff61 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -40,6 +40,7 @@ directory → generated defaults. Host processes use `127.0.0.1` for the develop | `TOPIC_STREAM_POSTGRES_USER` | PostgreSQL user for both Compose projects; default `topic_stream` | | `TOPIC_STREAM_POSTGRES_PASSWORD` | Local development password for both Compose projects; default `topic_stream` | | `TOPIC_STREAM_HTTP_ADDR` | Host listener, default `127.0.0.1:7331` | +| `TOPIC_STREAM_CORS_ALLOWED_ORIGINS` | Comma-separated exact browser Origin allowlist; default `app://obsidian.md`; explicit empty disables CORS | | `TOPIC_STREAM_QDRANT_URL` | gRPC endpoint, default `http://127.0.0.1:6334` | | `TOPIC_STREAM_COMPOSE_HTTP_ADDR` | Container HTTP listener, default `0.0.0.0:7331` | | `TOPIC_STREAM_COMPOSE_QDRANT_URL` | Container Qdrant gRPC endpoint, default `http://qdrant:6334` | @@ -222,3 +223,22 @@ through a trusted private network or an externally authenticated TLS proxy and s Obsidian Server URL accordingly. A container's `0.0.0.0` listener does not itself change the host port's localhost binding; adjust deployment networking deliberately. Topic Stream has no built-in authentication, including on its admin API. Keep PostgreSQL and Qdrant private. + +## Browser access and SSE + +`TOPIC_STREAM_CORS_ALLOWED_ORIGINS` applies to the entire HTTP API, including admin operations. +For example, `app://obsidian.md,https://reader.example` grants browser access to both exact +Origins. Values are client Origins (scheme, host and optional port), not the backend address. +Wildcards and patterns are rejected, and Echo validates the configured Origin format. +The opaque Origin `null` is unsupported. Credentials are not enabled. + +An explicitly empty value, such as `TOPIC_STREAM_CORS_ALLOWED_ORIGINS=` in `.env`, disables the +CORS middleware. Process ENV takes precedence over `.env` and generated defaults, including when +its value is empty. Restart the host process, or recreate Compose services with `mise run up`, +to apply a change. Editing this list does not change the network listener or add authentication. + +Preflight is handled before OpenAPI validation. Allowed request headers are Content-Type, +If-Match and Idempotency-Key; browser code can read ETag. Standard API methods are GET, POST, +PUT, PATCH and DELETE. The default Origin is verified with desktop Obsidian main/pop-out realms; +additional platform Origins require actual evidence rather than assumptions. REST uses Obsidian +requestUrl, while incremental SSE uses browser streaming and therefore needs a permitted Origin. diff --git a/docs/plugin-quality.md b/docs/plugin-quality.md new file mode 100644 index 0000000..4a7eecc --- /dev/null +++ b/docs/plugin-quality.md @@ -0,0 +1,83 @@ +# Obsidian plugin quality record + +Verified on 2026-10-28 for the `0.1.0` working-tree candidate. This records implementation and +local release readiness; [release workflows](releases.md) bind publication to a reviewed immutable +commit and create a draft. Local verification creates no Git tag or GitHub Release. + +## Required checks + +| Boundary | Command / evidence | +| --- | --- | +| OpenAPI | `pnpm contract:lint`; valid authoritative contract | +| Plugin | `pnpm --dir plugin check`; generated-type drift, typecheck, lint, browser dependency build, 166 unit tests | +| Connected UI | `pnpm --dir plugin test:storybook`; 272 browser interaction/accessibility tests | +| Build outputs | `pnpm --dir plugin build` and `build:storybook`; minified plugin and static catalog | +| Real Obsidian | `pnpm --dir plugin test:e2e:headless`; nine production-bundle product flows in an isolated vault | +| Release automation | `mise run test-ci` (21 tests), `mise run lint-ci`; preparation/version rejection and workflow syntax | +| Backend change | Full Go suite/lint and scoped CORS/config/runtime race tests passed for approved global CORS | + +The native host is Obsidian 1.8.7 with the earliest compatible installer (Electron 28 / Chromium +120). `test:e2e` uses a window; `test:e2e:headless` uses Docker/Xvfb and the pinned Playwright +1.64.1 Noble image. CI installs the lockfile and runs these same gates; failure logs persist under +`.artifacts/obsidian/logs/`. The native tests use external API fixtures while retaining the real +plugin bundle, Obsidian lifecycle, Markdown renderer and Vault/FileManager behavior. + +## Scenario ownership + +| Product behavior | Evidence owner | +| --- | --- | +| Commands, roots, URL replacement, local preferences and reload | Native foundation flow and ViewConnection/ItemView tests | +| Source types, nested Source picker, draft/dirty exit, Prefer/Avoid and conflicts | Settings production stories and source/transition owner tests | +| All/Topic publication boundaries, Unread retention, older pages and committed projection-pending writes | Feed production stories, typed-client tests and native Feed flow | +| Search, direct Reader versus Details, missing content, image consent and no implicit Vault writes | Search/Reader stories, reader owner tests and native Markdown flow | +| Explicit-only summaries, canonical/captured versions, permissions, failure and eligible Job Retry | Shared ItemSummaries tests, connected Shell/Reader/Digest stories and native summary flow | +| Manual-only definitions, safe Build, immutable editions, readiness baseline/offline badges and one live Notice | Digest/Settings/Tasks stories, readiness/build tests and native Digest/SSE flows | +| Five indexing phases, count/terminal precedence, shared queries, model replacement/recovery and outcome Notices | Job progress, transition, Settings and Tasks owner tests/stories | +| Compact/Full, fixed metadata, image modes/budgets, mixed consent, overwrite protection and rollback | Snapshot owner suites and three native Vault flows | +| Real streaming, malformed/interrupted input, bounded retries, stale callbacks and no polling | Browser transport/parser/connection tests and native SSE flow | +| Multi-view deduplication, dropped-advisory recovery, explicit REST fallback, pop-out move and unload | Native production SSE flow; shared authoritative-resource tests | + +The SSE feasibility probe additionally runs against the actual Go SSE visitor and production Echo +CORS factory when `SSE_PROBE_URL` is supplied. That combined run passed ten scenarios and measured +connection chunks in 3–7 ms and Job advisories in 266–330 ms in distinct main/pop-out realms, with +zero remaining server subscriptions. Buffered `requestUrl` exposed only completed text after +about five seconds. See [the transport decision](adr/0002-contract-first-rest-with-advisory-sse.md) +and [operator CORS configuration](operations/configuration.md#browser-access-and-sse). + +Native error collection retains application exceptions/rejections. Chromium's exception-free +deferred ResizeObserver notification is recorded separately, bounded and checked for settling +after window migration. This browser layout notification is defined by the +[Resize Observer specification](https://www.w3.org/TR/resize-observer/#deliver-resize-loop-error). +The production bundle contains no ResizeObserver implementation. + +## Responsive and mobile evidence + +The final connected Shell was rendered at 1180, 460 and 360 CSS-pixel viewports in both themes. +Forty-six captures cover Feed/Reader, Search, archive/edition, Tasks and Settings, plus 200% text +in Settings and editions. Geometry checks found no horizontal overflow or browser errors. +English/Spanish long content, title/Topic/Item/metadata hierarchy, focus, reduced motion and +synthesized Chromium touch navigation were checked. Two regression stories lock whole-word +navigation/headings and full-width narrow Settings panels. Independent visual review scored its +one responsive correction resolved and returned `ship`; the existing matte/peach system is retained. + +The mobile gate bundles the actual runtime entry with browser resolution: 337 modules, with only +the public Obsidian API external. ESLint rejects Node/Electron imports, desktop filesystem adapters +and Node globals in handwritten runtime code. Negative browser-build controls reject filesystem +and Electron imports. Fetch streaming, Markdown and Vault operations use browser/public host APIs. +Physical iOS/Android smoke is outside the accepted v0.1 gate and has not been performed. + +## Release assets and limits + +An isolated Linux build copied current source without existing `main.js`, installed the frozen +lockfile and rebuilt the release files. Its `main.js`, `manifest.json` and `styles.css` match the +local build byte-for-byte. The minified script is 1,063,167 bytes with no source map; metadata agrees +on `0.1.0`, minimum Obsidian `1.8.7`, and `isDesktopOnly: false`. The local initial-release policy +check requires no metadata changes. The ignored `.artifacts/plugin-release-clean/` directory +contains the three assets and a SHA-256 verification record. + +Release CI checks the actual remote version baseline, exact source/tag binding and metadata, +compares the downloaded asset manifest with source, then attaches exactly the three files to a +draft. Publishing remains manual. The published-baseline API-v1 compatibility checker and Community +Plugins directory submission remain deferred; neither is presented as a delivered check. +Generated bundles, local vault/cache/report paths and release staging remain ignored. Source, +stories, design/product documentation and generated contract types are eligible for source control. diff --git a/docs/product/capabilities/digests-and-schedules.md b/docs/product/capabilities/digests-and-schedules.md index 924037b..5e26e1c 100644 --- a/docs/product/capabilities/digests-and-schedules.md +++ b/docs/product/capabilities/digests-and-schedules.md @@ -1,6 +1,6 @@ # Digests and schedules -A Digest Definition stores one IANA timezone, one structured recurrence, and an ordered list of Topic relationships. Each relationship has its own `itemLimit` from 1 through 15. Definitions are versioned to prevent concurrent edits from silently overwriting each other. Retained immutable issues survive definition deletion. These capabilities are available through the backend API; the current Obsidian plugin has no Digest controls. +A Digest Definition stores one IANA timezone, one structured recurrence, and an ordered list of Topic relationships. Each relationship has its own `itemLimit` from 1 through 15. Definitions are versioned to prevent concurrent edits from silently overwriting each other. Retained immutable issues survive definition deletion. The backend API and Obsidian plugin expose definition management, explicit Build and saved Issue reading; see the [current Obsidian experience](obsidian-feed.md). Recurrence supports daily, a weekly subset of ISO weekdays, and monthly day-of-month, nth-weekday, or last-day rules. The scheduler chooses the first valid instant when a local wall-clock time does not exist and runs only the first occurrence when a wall-clock time repeats. It enqueues one catch-up generation when several intervals were missed, advances directly to the next occurrence after the current time, and does not move the calendar for a manual Build. diff --git a/docs/product/capabilities/durable-jobs.md b/docs/product/capabilities/durable-jobs.md index 21e1646..854d654 100644 --- a/docs/product/capabilities/durable-jobs.md +++ b/docs/product/capabilities/durable-jobs.md @@ -3,7 +3,8 @@ Synchronization, generation, model transitions, indexing, and Feed preparation run as durable Jobs. The Job API lists work for the current Workspace and exposes status, attempts, timestamps, duration, cancellation state, and safe diagnostic reasons. Internal arguments are not exposed. The current -Obsidian plugin does not yet provide Task Center controls. +Obsidian plugin provides Task Center list/detail, explicit Refresh, confirmed Cancel and eligible +Retry controls; see the [current Obsidian experience](obsidian-feed.md). Jobs move through `queued`, `running`, `retry_wait`, `succeeded`, `dead`, or `cancelled`. Temporary failures retry within a bounded policy; permanent failures stop. Fetch, retrieval, and Feed preparation diff --git a/docs/product/capabilities/item-ai-summaries.md b/docs/product/capabilities/item-ai-summaries.md index 0355e83..55a462b 100644 --- a/docs/product/capabilities/item-ai-summaries.md +++ b/docs/product/capabilities/item-ai-summaries.md @@ -1,8 +1,9 @@ # Item AI summaries An Item AI Summary is an optional generated annotation, separate from an ordinary excerpt. -The backend provides this capability through its API; Obsidian summary controls and Vault Snapshot -writes are not yet available in the current plugin. +The backend provides this capability through its API. The Obsidian plugin exposes explicit +summary controls in Reader, Item menus and saved editions. Explicit Vault Snapshots include ready +exact-version annotations without generating, retrying or waiting for summaries. ## Explicit requests and supported content diff --git a/docs/product/capabilities/obsidian-feed.md b/docs/product/capabilities/obsidian-feed.md index df44765..1c451d6 100644 --- a/docs/product/capabilities/obsidian-feed.md +++ b/docs/product/capabilities/obsidian-feed.md @@ -1,24 +1,131 @@ # Obsidian experience -The current plugin opens one Feed view through its ribbon action or **Open feed** command. -Its Server URL setting connects to the backend. It provides Topic creation with scope and -Include/Exclude criteria, and **Sync all now** with the returned Job ID. The Source form sends -the old request shape and cannot create Sources against the current contract; use the -[Source creation API example](../../guides/getting-started.md#get-your-first-feed). - -The view reads saved Feed history and loads older pages as the user scrolls. **Refresh** rereads -the latest saved history through GET; the current plugin does not invoke the backend's POST -Refresh publication operation or publish on first visit. Publish through the API using the -[first Feed instructions](../../guides/getting-started.md#get-your-first-feed). - -Cards show metadata, an excerpt and recommendation explanation. Item titles open the original URL. -**Useful** and **Not for me** save Like/Dislike feedback with an optional comment. - -Topic Feed switching, Topic corrections, Reading State and visibility controls, Search, Reader, -Digests, Task Center, Workspace Settings, Item AI summaries and Vault Snapshot writes are not yet -available in the plugin. The current implementation does not establish the complete responsive -desktop/mobile experience or SSE reconciliation. - -See [Using Topic Stream](../../guides/using-topic-stream.md) for available controls. The implemented -server behavior is described in [Feed and feedback](feed-and-feedback.md) and the other -[backend capabilities](../README.md#capabilities). +The plugin opens one reading view through the ribbon or **Open feed** command. Its responsive shell +has Feed, Digests and Search tabs plus Settings and Tasks controls. Feed publishes ready +recommendations and reads saved history. Search browses the archive and opens the same Reader; +Digests opens saved editions, and Tasks inspects background work. Wide leaves show list and detail together. +Narrow leaves restore list scroll and focus on Back. Reading section, selection and positions stay +local to the backend connection. + +Native plugin settings contain device-local Server URL, a draft connection test and explicit Apply, +plus vault-scoped default All/Unread and Snapshot preferences. Typing or testing does not replace +open clients. Applying the URL clears the previous connection’s caches and suppresses late results. +Unresolved Build identities and model outcome watches are scoped to endpoint and public Workspace +identity and remain device-local. + +Settings manages all supported Source adapters, full Topic scope/criteria, ordered Digest +Definitions and recurrence, non-secret Workspace Settings, global Prefer/Avoid and backend status. +Source creation orchestrates a separate initial Sync without a preflight probe; failed Sync retains +the Source and offers a Sync-only retry. Per-Source Job associations last only for the current view +session. Dirty editors offer Save/Discard/Stay, and versioned resources preserve drafts through +explicit conflict review. Deletion confirms the resource and retains saved Digest Issues. + +New Digest Definitions default to Manual only, retaining a valid calendar and timezone while +scheduling is off. Manual Build remains available. Uncertain acknowledgements reuse the persisted +request identity rather than creating another deliberate Build. Saving a Definition never builds. + +Workspace Settings distinguishes Desired and Serving Embedding Models, confirms replacement of +active preparation, and displays authoritative phase/count without inferring completion from a full +bar. Public current-operation references govern Cancel/Retry and recovery. Device-local watches +deduplicate user-initiated terminal Notices; explicit Refresh is available before advisory event +transport is connected. AI generation is opt-in and separate from retrieval. Saving settings never +generates text. Prefer/Avoid guides optional Digest reranking without adding another Feed/Search +ranking promise. Credentials remain operator-owned. + +The searchable Topic switcher offers **All Topics** and enabled Topics. Each selection attempts +publication once on its first visit in an opened view. **Refresh** deliberately checks for ready +recommendations again. Failed or incomplete attempts never retry publication automatically; +saved content remains usable. Returning to a visited selection restores its loaded pages, +selection and scroll. Older pages keep the original snapshot and publication order. A successful +Refresh with new publications replaces that traversal and moves to its new top; membership-only +changes preserve the reading position. + +The vault-scoped **All / Unread** filter applies to loaded pages. A displayed Item becoming read +stays dimmed in the current Unread traversal until a successful Refresh reapplies the filter. +Older history stays accessible when the loaded pages contain no unread Items. Opening a title +opens the reading pane and marks the Item read; **Open original** and explicit **Mark as read** +also update Reading State. **Why this item** shows the full explanation and metadata without +marking it read. Reader displays the saved canonical Markdown inside the view without creating a +vault file. Code blocks remain plain source text and do not invoke language-specific Obsidian +handlers. Empty/unavailable content retains the title, metadata, excerpt and navigation; an +explicit content check or rendering retry provides recovery where applicable. + +The Item's **…** menu offers **Useful** and **Not for me**. Choosing one saves the rating immediately +and opens a small blurred panel for an optional feedback note. Only **Send note** (the paper-plane +button) sends the text; closing the panel keeps the rating and preserves an unsent draft for that +opened Item. **Add note** / **Edit note** in the menu reopens it. Sending failures keep the draft and +the previously saved rating. The row and reading pane share the current rating and unsent draft. +**Not for me** retains the Item and offers a transient **Wrong topic** action in the note panel. + +**Add to Topic**, **Wrong topic** and **Use automatic matching** correct Topic membership at the +original publication position. **Hide from all Feeds** changes global visibility. These actions +do not publish or rerank Items. A saved feedback/correction write awaiting retrieval projection +stays saved and reports that pending state; it is not automatically submitted again. + +Empty Search browses newest-first across all time. A query uses hybrid retrieval; Topic, Source +and UTC publication-date filters apply only after explicit submission. Each session loads 20 +results per page, up to 100; changing a query or filters starts a new session. Hidden Items remain +discoverable, with a Hidden label. Search does not infer Topic rejection from missing membership. +Feedback, corrections and Reading State are shared with Feed; correcting a Topic rereads Search +with its existing filters. Retrieval preparation failures stay contextual to Search and preserve +usable Reader content. + +Remote images remain behind keyboard-accessible placeholders. The first activation loads all +known images for that Item, with independent consent for other Items. Consent and successful +images last for the opened view; a failed image has its own Retry and never replaces readable text. + +**AI summary** is explicit: a compact Reader button and an Item menu entry in lists. Server +availability controls generation; unsupported input and generation opt-out hide new work. Ready +annotations remain readable when generation is disabled. A request immediately opens a local +pending block before the original text. Reading, Refresh, navigation and status checks never +generate summaries. Matching ready text is reused; failures retry only the eligible summary Job. +Status checks are manual until advisory transport is connected. Summary completion preserves +Reading State, list order, focus, reading position and disclosures in other contexts. An annotation +is bound to its exact source version; changing that version collapses the previous disclosure. + +The Digest archive is newest-first and filters by saved Definition. Whole-edition reading keeps +its Topic structure and ordinary excerpts; it does not mark Items read. Opening an Item uses its +captured content and replaces the wide archive pane with compact contents. Back restores edition +position and title focus. A partial Issue preserves readable content and its saved failure context. +The Item overflow shares feedback, explanation and explicit exact-version AI-summary behavior. +Late annotations never replace saved edition facts or change its complete/degraded status. + +Build chooses a saved Definition, including Manual only. Shared safe request identities survive +uncertain responses and reopening. Completion exposes the Issue identified by generationJobId and +never replaces the current reader. A committed-result Retry conflict reconciles the archive without +submitting a replacement Build. Definition management remains secondary to the reading archive. + +Device-local readiness establishes one first-successful unfiltered baseline, covering older pages +as existing history. Later mount/reconnect/manual reads reconstruct all unopened post-baseline +badges without Notices. Opening an exact Issue records it as seen. Live event consumers claim at +most one readiness Notice across views; device, backend and verified Workspace scopes are isolated. +One view-owned browser SSE connection supplies live advisories; mounted views also retain explicit +Refresh when streaming is unavailable. Reconnect restores REST state without publication or generation. + +Task Center reads five queues, optional progress, attempts, reason, timestamps and recorded duration. +No private arguments, invented ETA or polling appear. Mount, relevant advisories, reconnect and +explicit Refresh reconcile REST state. Settings and Tasks share Job queries/actions and one model +outcome boundary; an event coalesces an affected Job read across observers. Cancel confirms scope +and remains pending until authoritative completion. Retry follows the returned durable successor; +unknown/superseded model preparation has no unsafe Retry or inferred historical model target. +Loaded task details survive a failed read with a local recovery action. + +**Save to vault** is available from every Item context and the whole-edition menu. A protected-focus +form asks for normalized folder/filename and Compact/Full; existing destinations are rejected. +Full is unavailable for individual description/abstract-only inputs with an explicit Compact +switch, while mixed Full Digests require consent and label each missing body **Excerpt only**. +Source metadata, ordered captured Digest text and matching ready annotations remain independent +of later canonical changes. Save never generates, retries or waits for AI work. + +Full image modes are Links, Remote and Local. Local uses only captured Item descriptors, strict +static SVG sanitization, portable relative attachments and per-operation 30-file/10 MB/100 MB +budgets across a Digest. Unavailable/over-budget assets retain original links with a truthful +result. Serialized Vault/FileManager writes never overwrite and roll back only newly created +attachments if the note fails. Content/image choices persist per vault; saved notes are never +mirrored or updated by later work. Reader remains read-only. + +Desktop main/pop-out SSE and teardown are verified. Physical mobile feature parity is not claimed +by browser-safe adapters, responsive previews or desktop host tests. + +See [Using Topic Stream](../../guides/using-topic-stream.md) for current controls and +[backend capabilities](../README.md#capabilities) for server behavior. diff --git a/docs/product/capabilities/search-reader-and-events.md b/docs/product/capabilities/search-reader-and-events.md index 703780b..73d96cd 100644 --- a/docs/product/capabilities/search-reader-and-events.md +++ b/docs/product/capabilities/search-reader-and-events.md @@ -1,7 +1,8 @@ # Search, Reader, and notifications -These are backend/API capabilities. The current Obsidian plugin has no Search, Reader or SSE -reconciliation controls; see the [current Obsidian experience](obsidian-feed.md). +These are backend/API capabilities. The Obsidian plugin provides archive Search and Reader, +including saved-edition content and view-owned advisory SSE. See the +[current Obsidian experience](obsidian-feed.md) for client controls and refresh boundaries. Search supports lexical, semantic, and hybrid matching with optional Topic, Source, Item-kind, and publication-date filters. An empty query browses the newest archive content. Non-empty queries use @@ -14,7 +15,8 @@ position without republishing it or recomputing its recommendation explanation. The content API provides canonical cleaned Markdown for Reader clients when available. Merely retrieving an Item does not fetch remote images; image requests are validated and bounded. -Vault Snapshot writes belong to the client and are not implemented in the current plugin. The +Explicit Vault Snapshot writes are client-owned and available through Save to vault; Reader stays +read-only. The backend does not synchronize files in the vault; see the [vault boundary decision](../decisions/0004-explicit-vault-snapshots.md). @@ -25,6 +27,13 @@ Notifications signal Feed invalidation, a new Digest Issue, or a Job status chan reconnecting clients refetch authoritative state rather than replaying missed events. A notification does not insert cards into a loaded Feed. Slow clients cannot block background work. +The plugin shares one Fetch/ReadableStream connection per open view, cancels it on close, +endpoint replacement and unload, and reopens it in the owning window after pop-out migration. +Retries back off to 30 seconds; failed streaming retains explicit REST recovery and loaded content. +Desktop main/pop-out delivery is verified; the browser-only adapter has no Node/Electron path, +but physical mobile streaming is not part of the v0.1 evidence. Exact-origin +[CORS configuration](../../operations/configuration.md#browser-access-and-sse) is required. + See the [ranking pipeline](../../architecture/ranking-pipeline.md) for Search scoring differences, [content ingestion and Reader delivery](../../architecture/content-ingestion-and-reader.md) for content/image mechanics, and [runtime roles](../../architecture/jobs-and-runtime-roles.md#live-settings-and-advisories) diff --git a/docs/releases.md b/docs/releases.md index c6075b7..2029bba 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -49,6 +49,9 @@ Run **Plugin release** after plugin metadata has been prepared and reviewed. The the dispatch SHA, forces plugin and contract checks, verifies that `package.json`, `manifest.json` and `versions.json` agree, builds the plugin and uploads `main.js`, `manifest.json` and `styles.css` to a draft `X.Y.Z` Release. It uses `latest=true` for the plugin release. +The downloaded asset manifest is compared byte-for-byte with the validated source manifest. +Its reused CI gate includes generated types, mobile dependency boundaries, Storybook and the +production-bundle headless Obsidian flows; see [plugin quality](plugin-quality.md). An existing tag or draft/Release is rejected; the workflow does not resume an incomplete plugin Release. diff --git a/gen/config/config.gen.go b/gen/config/config.gen.go index 32a25de..b652c19 100644 --- a/gen/config/config.gen.go +++ b/gen/config/config.gen.go @@ -39,8 +39,9 @@ type DBPrimaryConfig struct { } type HTTPConfig struct { - Address string `env:"TOPIC_STREAM_HTTP_ADDR" default:"127.0.0.1:7331"` - Enabled bool `env:"TOPIC_STREAM_HTTP_SERVER_ENABLED" default:"true"` + Address string `env:"TOPIC_STREAM_HTTP_ADDR" default:"127.0.0.1:7331"` + CorsAllowedOrigins string `env:"TOPIC_STREAM_CORS_ALLOWED_ORIGINS" default:"app://obsidian.md"` + Enabled bool `env:"TOPIC_STREAM_HTTP_SERVER_ENABLED" default:"true"` } type HealthConfig struct { diff --git a/go.mod b/go.mod index e5642c6..67fc37e 100644 --- a/go.mod +++ b/go.mod @@ -105,6 +105,7 @@ require ( golang.org/x/mod v0.38.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index a0e5cf8..04365bb 100644 --- a/go.sum +++ b/go.sum @@ -338,6 +338,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/internal/deps/api.go b/internal/deps/api.go index 47cb687..fe498c3 100644 --- a/internal/deps/api.go +++ b/internal/deps/api.go @@ -21,6 +21,7 @@ import ( "github.com/ethernity/obsidian-topic-stream/internal/service/sourcepoller" "github.com/ethernity/obsidian-topic-stream/internal/service/topiccalibration" "github.com/google/uuid" + "github.com/labstack/echo/v5" ) // API owns the dependency container and independently enabled daemon roles. @@ -38,6 +39,7 @@ type API struct { } type runtimeValues struct { + cors echo.MiddlewareFunc context context.Context version string workspaceID uuid.UUID @@ -208,7 +210,11 @@ func validateRuntime(ctx context.Context, cfg generated.Config, version string) if err := domain.ValidateEmbeddingModelDescriptor(bootstrapEmbeddingDescriptor(cfg.Bootstrap)); err != nil { return runtimeValues{}, fmt.Errorf("bootstrap embedding configuration: %w", err) } - return runtimeValues{context: ctx, version: version, workspaceID: workspaceID, searchPolicy: searchPolicy}, nil + cors, err := runtimeCORS(cfg.HTTP) + if err != nil { + return runtimeValues{}, fmt.Errorf("HTTP CORS configuration: %w", err) + } + return runtimeValues{context: ctx, version: version, workspaceID: workspaceID, searchPolicy: searchPolicy, cors: cors}, nil } func validateRuntimeRoles(cfg generated.Config) (uuid.UUID, error) { diff --git a/internal/deps/api_test.go b/internal/deps/api_test.go index 0986fad..e8fd1df 100644 --- a/internal/deps/api_test.go +++ b/internal/deps/api_test.go @@ -53,7 +53,7 @@ func TestNewAPIAllowsWorkerOnlyRuntimeWithoutWorkspaceID(t *testing.T) { _, err := deps.NewAPI(t.Context(), generated.Config{ Bootstrap: generated.BootstrapConfig{BootstrapEmbeddingEndpoint: "http://fixture.example/v1", BootstrapEmbeddingModel: "synthetic", BootstrapEmbeddingDimensions: 3}, DBPrimary: generated.DBPrimaryConfig{Kind: "postgres", PostgresDSN: "postgres://unreachable.invalid/topic_stream"}, - HTTP: generated.HTTPConfig{Enabled: false}, + HTTP: generated.HTTPConfig{Enabled: false, CorsAllowedOrigins: "*"}, Runtime: generated.RuntimeConfig{EmbeddingBatchSize: 8, EmbeddingBatchInputBytes: 1 << 20, EmbeddingConcurrency: 1, SchedulersEnabled: false, WorkersEnabled: true, WorkerFetchConcurrency: 4, WorkerFetchPerHostConcurrency: 1, @@ -63,4 +63,22 @@ func TestNewAPIAllowsWorkerOnlyRuntimeWithoutWorkspaceID(t *testing.T) { require.Error(t, err) require.NotContains(t, err.Error(), "workspace ID") + require.NotContains(t, err.Error(), "CORS") +} + +func TestNewAPIValidatesCORSBeforeAcquiringResources(t *testing.T) { + for _, origin := range []string{"*", "https://*.example.com", "null", "https://example.com/path"} { + t.Run(origin, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + _, err := deps.NewAPI(ctx, generated.Config{ + Bootstrap: generated.BootstrapConfig{BootstrapEmbeddingEndpoint: "http://fixture/v1", BootstrapEmbeddingModel: "fixture", BootstrapEmbeddingDimensions: 3}, + DBPrimary: generated.DBPrimaryConfig{Kind: "postgres", PostgresDSN: "postgres://unreachable.invalid/test"}, + HTTP: generated.HTTPConfig{Enabled: true, Address: "127.0.0.1:0", CorsAllowedOrigins: origin}, + Runtime: generated.RuntimeConfig{WorkspaceId: uuid.NewString(), EmbeddingBatchSize: 8, EmbeddingBatchInputBytes: 1 << 20, EmbeddingConcurrency: 1}, + }, "test") + require.ErrorContains(t, err, "CORS") + require.NotContains(t, err.Error(), "connect") + }) + } } diff --git a/internal/deps/cors.go b/internal/deps/cors.go new file mode 100644 index 0000000..b0a6816 --- /dev/null +++ b/internal/deps/cors.go @@ -0,0 +1,22 @@ +package deps + +import ( + "strings" + + generated "github.com/ethernity/obsidian-topic-stream/gen/config" + "github.com/ethernity/obsidian-topic-stream/internal/transport/httpapi" + "github.com/labstack/echo/v5" +) + +func runtimeCORS(cfg generated.HTTPConfig) (echo.MiddlewareFunc, error) { + if !cfg.Enabled { + return nil, nil + } + var origins []string + for _, value := range strings.Split(cfg.CorsAllowedOrigins, ",") { + if origin := strings.TrimSpace(value); origin != "" { + origins = append(origins, origin) + } + } + return httpapi.NewCORSMiddleware(origins) +} diff --git a/internal/deps/providers.go b/internal/deps/providers.go index 6de5974..f7fffd7 100644 --- a/internal/deps/providers.go +++ b/internal/deps/providers.go @@ -899,7 +899,7 @@ func resolveRouterCore(resolver di.Resolver) (httpapi.Dependencies, error) { return httpapi.Dependencies{}, err } return httpapi.Dependencies{ - WorkspaceID: values.workspaceID, Status: status, Feed: feedService, + WorkspaceID: values.workspaceID, Status: status, Feed: feedService, CORS: values.cors, Sources: sourceService, Jobs: jobService, Items: itemService, }, nil } diff --git a/internal/platform/config/load.go b/internal/platform/config/load.go index d1e6a43..f95f92c 100644 --- a/internal/platform/config/load.go +++ b/internal/platform/config/load.go @@ -30,6 +30,11 @@ func Load(ctx context.Context) (generated.Config, error) { return generated.Config{}, fmt.Errorf("load configuration: %w", err) } preserveBootstrapPresence(&loaded.Bootstrap, fileValues) + if value, present := os.LookupEnv("TOPIC_STREAM_CORS_ALLOWED_ORIGINS"); present { + loaded.HTTP.CorsAllowedOrigins = value + } else if value, present := fileValues["TOPIC_STREAM_CORS_ALLOWED_ORIGINS"]; present { + loaded.HTTP.CorsAllowedOrigins = value + } if err := loaded.Validate(); err != nil { return generated.Config{}, fmt.Errorf("validate configuration: %w", err) } diff --git a/internal/platform/config/load_test.go b/internal/platform/config/load_test.go index db8aaa3..a56191f 100644 --- a/internal/platform/config/load_test.go +++ b/internal/platform/config/load_test.go @@ -56,3 +56,33 @@ func TestLoadRejectsInvalidDuration(t *testing.T) { require.ErrorContains(t, err, "ShutdownTimeout") } + +func TestLoadCORSDefault(t *testing.T) { + t.Chdir(t.TempDir()) + loaded, err := config.Load(t.Context()) + require.NoError(t, err) + require.Equal(t, "app://obsidian.md", loaded.HTTP.CorsAllowedOrigins) +} + +func TestLoadCORSPreservesFileAndProcessPresence(t *testing.T) { + for _, scenario := range []struct { + name, file, want string + process *string + }{ + {name: "file list", file: "https://file.example", want: "https://file.example"}, + {name: "file explicitly disabled", file: "", want: ""}, + {name: "process replaces file", file: "https://file.example", process: new("app://obsidian.md,https://process.example"), want: "app://obsidian.md,https://process.example"}, + {name: "empty process disables file", file: "https://file.example", process: new(""), want: ""}, + } { + t.Run(scenario.name, func(t *testing.T) { + t.Chdir(t.TempDir()) + require.NoError(t, os.WriteFile(".env", []byte("TOPIC_STREAM_CORS_ALLOWED_ORIGINS="+scenario.file+"\n"), 0o600)) + if scenario.process != nil { + t.Setenv("TOPIC_STREAM_CORS_ALLOWED_ORIGINS", *scenario.process) + } + loaded, err := config.Load(t.Context()) + require.NoError(t, err) + require.Equal(t, scenario.want, loaded.HTTP.CorsAllowedOrigins) + }) + } +} diff --git a/internal/transport/httpapi/cors.go b/internal/transport/httpapi/cors.go new file mode 100644 index 0000000..0e17c7e --- /dev/null +++ b/internal/transport/httpapi/cors.go @@ -0,0 +1,28 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" +) + +// NewCORSMiddleware builds the API's exact-origin browser access policy; an empty list disables it. +func NewCORSMiddleware(origins []string) (echo.MiddlewareFunc, error) { + if len(origins) == 0 { + return nil, nil + } + for _, origin := range origins { + if strings.Contains(origin, "*") { + return nil, errors.New("CORS origins must be exact; wildcards are not supported") + } + } + return (middleware.CORSConfig{ + AllowOrigins: origins, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}, + AllowHeaders: []string{"Content-Type", "If-Match", "Idempotency-Key"}, + ExposeHeaders: []string{"ETag"}, + }).ToMiddleware() +} diff --git a/internal/transport/httpapi/cors_test.go b/internal/transport/httpapi/cors_test.go new file mode 100644 index 0000000..02fcaa3 --- /dev/null +++ b/internal/transport/httpapi/cors_test.go @@ -0,0 +1,114 @@ +package httpapi_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethernity/obsidian-topic-stream/internal/transport/httpapi" + "github.com/ethernity/obsidian-topic-stream/internal/transport/httpapi/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func corsRouter(t *testing.T, origins []string) http.Handler { + t.Helper() + policy, err := httpapi.NewCORSMiddleware(origins) + require.NoError(t, err) + dependencies := completeTestDependencies(t) + dependencies.CORS = policy + router, err := httpapi.NewRouter(dependencies) + require.NoError(t, err) + return router +} + +func TestCORSPreflightCoversProductAndAdminRoutesBeforeValidation(t *testing.T) { + router := corsRouter(t, []string{"app://obsidian.md", "https://reader.example"}) + for _, path := range []string{"/api/v1/events", "/api/v1/topics", "/api/v1/admin/embedding-models"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodOptions, path, nil) + request.Header.Set("Origin", "https://reader.example") + request.Header.Set("Access-Control-Request-Method", http.MethodPost) + request.Header.Set("Access-Control-Request-Headers", "Content-Type,If-Match,Idempotency-Key") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusNoContent, response.Code) + require.Equal(t, "https://reader.example", response.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "GET,POST,PUT,PATCH,DELETE", response.Header().Get("Access-Control-Allow-Methods")) + require.Equal(t, "Content-Type,If-Match,Idempotency-Key", response.Header().Get("Access-Control-Allow-Headers")) + require.Empty(t, response.Header().Get("Access-Control-Allow-Credentials")) + require.Empty(t, response.Header().Get("Access-Control-Max-Age")) + require.Contains(t, response.Header().Values("Vary"), "Origin") + }) + } +} + +func TestCORSPreservesOrdinaryRESTAndOnlyGrantsListedOrigins(t *testing.T) { + topics := mocks.NewMockTopics(gomock.NewController(t)) + topics.EXPECT().List(gomock.Any()).Return(nil, nil).Times(3) + policy, err := httpapi.NewCORSMiddleware([]string{"app://obsidian.md"}) + require.NoError(t, err) + dependencies := completeTestDependencies(t) + dependencies.Topics, dependencies.CORS = topics, policy + router, err := httpapi.NewRouter(dependencies) + require.NoError(t, err) + for _, origin := range []string{"app://obsidian.md", "https://other.example", ""} { + request := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) + request.Header.Set("Origin", origin) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusOK, response.Code) + if origin == "app://obsidian.md" { + require.Equal(t, origin, response.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "ETag", response.Header().Get("Access-Control-Expose-Headers")) + } else { + require.Empty(t, response.Header().Get("Access-Control-Allow-Origin")) + } + } +} + +func TestCORSHeadersReachSSEBeforeFirstFlushAndProblemResponses(t *testing.T) { + router := corsRouter(t, []string{"app://obsidian.md"}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + request := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil).WithContext(ctx) + request.Header.Set("Origin", "app://obsidian.md") + stream := &eventResponseWriter{ResponseRecorder: httptest.NewRecorder(), cancel: cancel} + router.ServeHTTP(stream, request) + require.Equal(t, ": connected\n\n", stream.Body.String()) + require.Equal(t, "app://obsidian.md", stream.Result().Header.Get("Access-Control-Allow-Origin")) + require.Equal(t, 1, stream.flushes) + + request = httptest.NewRequest(http.MethodGet, "/api/v1/not-an-operation", nil) + request.Header.Set("Origin", "app://obsidian.md") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusNotFound, response.Code) + require.Equal(t, "app://obsidian.md", response.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORSEmptyPolicyAndDisallowedPreflightNeverGrantAccess(t *testing.T) { + for _, origins := range [][]string{nil, {"app://obsidian.md"}} { + router := corsRouter(t, origins) + request := httptest.NewRequest(http.MethodOptions, "/api/v1/events", nil) + request.Header.Set("Origin", "https://other.example") + request.Header.Set("Access-Control-Request-Method", http.MethodGet) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Empty(t, response.Header().Get("Access-Control-Allow-Origin")) + if len(origins) > 0 { + require.Equal(t, http.StatusNoContent, response.Code) + } + } +} + +func TestCORSPolicyRejectsWildcardAndMalformedOrigins(t *testing.T) { + for _, origin := range []string{"*", "https://*.example.com", "null", "not-an-origin", "https://example.com/path", "https://example.com?token=value", "https://example.com#fragment"} { + t.Run(origin, func(t *testing.T) { + policy, err := httpapi.NewCORSMiddleware([]string{origin}) + require.Error(t, err) + require.Nil(t, policy) + }) + } +} diff --git a/internal/transport/httpapi/router.go b/internal/transport/httpapi/router.go index 77e9634..adae188 100644 --- a/internal/transport/httpapi/router.go +++ b/internal/transport/httpapi/router.go @@ -19,6 +19,7 @@ import ( // Dependencies supplies the Workspace-scoped capabilities exposed by the HTTP API. type Dependencies struct { + CORS echo.MiddlewareFunc WorkspaceID uuid.UUID Status *systemstatus.Service Feed Feed @@ -57,6 +58,9 @@ func NewRouter(dependencies Dependencies) (http.Handler, error) { router := echo.New() router.HTTPErrorHandler = handleHTTPError router.JSONSerializer = strictJSONSerializer{} + if dependencies.CORS != nil { + router.Use(dependencies.CORS) + } router.Use(validator) serverhttp.RegisterHandlers(router, serverhttp.NewStrictHandler(server, nil)) diff --git a/plugin/.impeccable/surfaces/plugin-src-features-shell-topicstreamshell-tsx.md b/plugin/.impeccable/surfaces/plugin-src-features-shell-topicstreamshell-tsx.md new file mode 100644 index 0000000..79eea5d --- /dev/null +++ b/plugin/.impeccable/surfaces/plugin-src-features-shell-topicstreamshell-tsx.md @@ -0,0 +1,131 @@ +--- +version: 1 +slug: "plugin-src-features-shell-topicstreamshell-tsx" +primary_target: "plugin/src/features/shell/TopicStreamShell.tsx" +related_targets: ["plugin/src/features/settings/SettingsWorkspace.tsx","plugin/src/features/feed/FeedView.tsx","plugin/src/features/digests/DigestsWorkspace.tsx","plugin/src/features/digests/DigestEdition.tsx","plugin/src/features/digests/DigestReader.tsx","plugin/src/features/digests/ArchiveBuilds.tsx","plugin/src/features/tasks/TasksWorkspace.tsx","plugin/src/features/tasks/TaskActions.tsx","plugin/src/features/tasks/TaskModelContext.tsx","plugin/src/shared/jobs/JobProgress.tsx","plugin/src/shared/jobs/job-actions.ts","plugin/src/shared/jobs/resources.ts","plugin/styles.css"] +--- + +# Topic Stream application surface + +Mode: Operate. Applies to the complete plugin Shell, reading lists/details, all Settings resources, +forms, menus, dialogs and states. Production components also own Storybook. Keep host light/dark, +leaf responsiveness, English UI, English/Spanish fixtures and all delivered workflows. + +## Direction contract + +THESIS: A composed, tactile reading workspace. One clear action per view; secondary operations +belong in real anchored menus. The user rejected the former collection of outlined buttons. + +OWN-WORLD: User-pinned Dribbble reference, downloaded and inspected at +`.artifacts/storybook-review/design-reference.png` in the repository root. Original source: +https://cdn.dribbble.com/userupload/12942608/file/original-4c287785698d505006c3f207a6b77987.png?resize=2048x1536&vertical=center +Black canvas, charcoal rounded work zones, lighter matte fields, bright neutral text and user-approved soft peach #F2B38A +selection. A lighter peach supports hover. A restrained warm peach selection wash and +translucent blurred editor/menu panels are the signature, with accessible contrast. +Light mode translates the same hierarchy into pale grounds and white work zones. + +STORY: Browse a calm list, focus one Item, then configure the Workspace without losing context. +All actions remain available, with labels and state rather than decorative control clutter. + +FIRST VIEWPORT: Compact branded navigation above separated rounded list and reading work areas. +Primary navigation wraps complete tab controls at constrained widths so labels stay whole. Settings +uses quiet section navigation, one resource heading and one primary Add/Save action; its heading and +resource controls wrap as groups, and the narrow resource panel stretches to the available width. +Rows show identity and concise metadata, with a single ellipsis opening a floating action menu. +Choosing Useful / Not for me saves the rating immediately and opens a compact, nonmodal blurred +note panel at that Item's action trigger. Notes are optional and send only with the paper-plane +Send note button. Closing does not undo the rating; unsent drafts survive reopening and errors. +Add note / Edit note stays in the Item menu, with no permanent inline Add a note control. + +DELIVERED FEED SLICE: All Topics and each selected Topic have independent first-visit and manual +refresh traversals; the searchable Topic picker is anchored to its trigger. The persisted All/Unread +filter retains read rows during the current traversal. A title opens the direct reading pane and marks +an unread Item read; Why this item opens explicit recommendation details without marking it read. +One overflow owns Useful/Not for me, Add/Edit note, Why this item, Open original, read/unread state, +Hide from all Feeds, and Add/Wrong/automatic Topic corrections. Not for me can reveal a transient +Wrong topic action in the optional-note panel. Canonical committed 503 responses preserve saved intent +and show retrieval-update-pending status; they do not roll the user's action back. + +DELIVERED SEARCH: Search is a quiet text field with an explicit Search action and a Filters action. +Filters opens an anchored nonmodal glass surface with Topic, Source, UTC publication-date bounds, +explicit Apply and clear/close cancellation, local option-load recovery, 20 results per request, and +a 100-result session cap. Search rows remain text-led, expose Hidden state, and reuse the same quiet +overflow actions and selected reading composition as Feed. + +DELIVERED READER: The connected shell owns the canonical Reader, with the Item title primary, source +headings subordinate, host-native reading prose near a 70ch measure, and keyboard-focusable source +code/tables with local horizontal scroll. Recognized fenced code is rendered as plain code without +invoking host code-block processors. Images are matte labelled placeholders with no thumbnail fetch; +first activation independently consents per Item and loads all known images through a bounded +four-download queue. Partial failures expose local Retry image actions, and object URLs are released +with the view. Item action notices stay near the Reader title and remain Item-scoped. + +DELIVERED AI SUMMARY: The compact labelled neutral AI summary action sits near the Reader title and +uses Item overflow in list contexts when supported. Its accessible labelled disclosure is local +to context and exact source version; pending appears before prose, ready text remains readable after +opt-out/collapse, generation is always explicit, failed work offers Job Retry, and status recovery is +read-only. Summary arrival preserves source, focus, and position; source-version changes collapse the +previous disclosure. Shared exact-version state is provided by ItemSummaries. + +DELIVERED DIGESTS AND TASKS: The connected Digest archive uses text-led Issue rows with definition +filtering, Refresh, a real Build menu, partial-Issue metadata, local loading/empty/recovery states, +and device-scoped peach New badges. Device-scoped readiness establishes a baseline from the first +successful unfiltered archive head once per device/backend/Workspace; subsequent mount/manual REST +reads reconstruct unopened post-baseline Issues without emitting catch-up Notices. Live host +Notice/event consumers are deduplicated, can add a new Issue after that baseline, and are tested at +the event port. UI 11 now delivers those advisories through a view-owned browser Fetch/ReadableStream +SSE connection with bounded reconnect, endpoint replacement, pop-out realm migration and teardown. +A saved Issue opens as an +Edition with title/date lead, section index and captured summaries; selecting an Item swaps the archive +for compact Issue contents and the captured Reader, with Back restoring selection and focus. Build +results expose one Open built issue action and local retry/uncertain/unavailable recovery. + +Task Center is the shared Job history with a status filter, 30-row pages, queue/status metadata and +detail facts. Shared Job progress shows phase and initial-cohort counts, uses peach normal fills and +muted failed/cancelled history fills, and keeps status authoritative when a count reaches N/N. WebKit +and Gecko pseudo-elements are separate. Active Jobs expose Cancel with a native confirmation dialog. +Ordinary dead/cancelled Jobs expose Retry; model-preparation Jobs additionally require a verified +current, eligible model target. Digest Builds, Settings and Task Center +observe the same Job queries and event port, including model-transition context and saved-result +recovery. + +DELIVERED SNAPSHOTS: Item menus in Feed/Search/Reader, captured Digest Item menus, and the whole +Edition `Issue actions` menu open one native `showModal()` Save to vault form. The form asks for +normalized folder and filename, remembers Compact/Full and Full-only Links/Remote/Local image choices +per vault, and creates an independent Markdown note without changing backend state. An individual +Item without article body/README explains why Full is unavailable, hides image controls, disables Save, +and offers explicit Use Compact. A mixed Full Digest reports its missing count, requires an inline +checkbox consent, and labels each omitted body Excerpt only; cancel leaves the form open and keeps the +choice available. Compact and Full include only ready AI annotations matching the captured Item input +version; Save never generates, waits for, retries, or mirrors later Summary work. Contextual +validation, overwrite/write errors, and pending progress remain in the native form. Full Local writes +bounded relative attachments after strict SVG sanitation, retains links on failures or budget limits, +and the serialized host Vault/FileManager boundary rolls back only attachments created by the attempt. +Item/Issue overflow triggers restore focus on dismissal or completion. Release evidence records 166 +unit tests, 272 Storybook browser tests, the mobile browser dependency-boundary check, nine native +production flows plus the separately verified SSE probe, and 46 dark/light desktop/narrow/mobile/ +200%-text captures of the final loaded Shell screens with zero page errors or horizontal overflow. +Separate prior feature matrices and interaction tests cover loading, empty, local failures, badges, +menus, partial Issues, cancellation, and failed/completed Job history; those states are not counted +among the 46 release captures. The one 460px Settings panel fix is resolved by the fresh ship review; +physical mobile-device parity remains unclaimed. + +SCROLL TREATMENT: The Feed header remains opaque at rest and reserves `32px` below its controls. Only +the scrolled list header uses the existing glass token with `blur(12px)`, with an opaque fallback; +anchored menus, Topic and Search filters, note panels, and editor panes retain their existing +`blur(28px)` rule. + +FORM: User-pinned visual reference, code-led implementation; no concept roll or generated comp. +Direction authority is the user's supplied reference and explicit peach selection. No random +seed was used or claimed; do not invent one retrospectively or reopen the user's chosen direction. +Standard Obsidian controls, host typography and keyboard/focus behavior carry the reference's +palette, density and corner language. Menus anchor without moving or clipping list rows. + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance + +LIVE EVENTS: UI 11 supplies actual browser Fetch/ReadableStream SSE with bounded reconnect, +authoritative REST reconciliation, endpoint/view lifecycle isolation, pop-out realm migration and +cross-view Notice deduplication. Unavailable streams keep reading and explicit Refresh usable without +automatic Feed publication, Summary generation or global offline chrome. Responsive frames and desktop +host tests do not claim physical mobile-device parity. Hidden capability fixtures and technical/ +captured-reader probes are contracts or harness-only evidence, not extra product UI delivery. diff --git a/plugin/.storybook/main.ts b/plugin/.storybook/main.ts new file mode 100644 index 0000000..70f5360 --- /dev/null +++ b/plugin/.storybook/main.ts @@ -0,0 +1,14 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + framework: "@storybook/react-vite", + stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y", "@storybook/addon-vitest"], + core: { disableTelemetry: true }, + viteFinal: (vite) => ({ ...vite, optimizeDeps: { + ...vite.optimizeDeps, + include: [...(vite.optimizeDeps?.include ?? []), "react-dom", "react-hook-form", "@hookform/resolvers/zod", "zod", "dompurify"] + } }) +}; + +export default config; diff --git a/plugin/.storybook/obsidian-theme.css b/plugin/.storybook/obsidian-theme.css new file mode 100644 index 0000000..38b35e7 --- /dev/null +++ b/plugin/.storybook/obsidian-theme.css @@ -0,0 +1,64 @@ +/* Curated host fixture, inspected against Obsidian API 1.8.7. + Sources: https://docs.obsidian.md/Reference/CSS+variables/About+styling + and the documented foundations/colors and foundations/typography variables. + Values approximate the host for isolated stories; this is not extracted application CSS. */ +.topic-stream-story-host { + --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-text: var(--font-interface); + --font-monospace: ui-monospace, monospace; + --font-text-size: 16px; + --background-primary: #ffffff; + --background-secondary: #f5f6f8; + --background-modifier-border: #d8dce3; + --text-normal: #252a33; + --text-muted: #5b626f; + --text-faint: #697180; + --text-accent: #4c59b6; + --text-error: #a52929; + --interactive-normal: #f0f2f6; + --interactive-hover: #e6e9ef; + --interactive-accent: #5965c5; + --text-on-accent: #ffffff; + --radius-s: 4px; + --radius-m: 8px; + font-family: var(--font-interface); + color: var(--text-normal); + background: var(--background-secondary); +} + +.topic-stream-story-host.theme-dark { + --background-primary: #1e222a; + --background-secondary: #181c23; + --background-modifier-border: #3b424f; + --text-normal: #edf0f5; + --text-muted: #b1b8c5; + --text-faint: #a5aebd; + --text-accent: #a3afff; + --text-error: #ff9898; + --interactive-normal: #303744; + --interactive-hover: #3a4352; + --interactive-accent: #a3afff; + --text-on-accent: #171c2b; +} + +.topic-stream-story-host button, +.topic-stream-story-host input, +.topic-stream-story-host select, +.topic-stream-story-host textarea { + font: inherit; +} + +.topic-stream-story-host button { + min-height: 44px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + padding: 8px 12px; + color: var(--text-normal); + background: var(--interactive-normal); + cursor: pointer; +} + +.topic-stream-story-host :focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 3px; +} diff --git a/plugin/.storybook/preview.ts b/plugin/.storybook/preview.ts new file mode 100644 index 0000000..dd4812b --- /dev/null +++ b/plugin/.storybook/preview.ts @@ -0,0 +1,18 @@ +import type { Preview } from "@storybook/react-vite"; + +import "./obsidian-theme.css"; +import "../styles.css"; +import "../src/test/storybook/host-frame.css"; +import { withHostFrame } from "../src/test/storybook/decorators"; + +const preview: Preview = { + decorators: [withHostFrame], + globalTypes: { + theme: { description: "Obsidian theme", toolbar: { icon: "paintbrush", items: ["light", "dark"], dynamicTitle: true } }, + frame: { description: "Workspace leaf width", toolbar: { icon: "browser", items: ["leaf", "narrow", "mobile"], dynamicTitle: true } } + }, + initialGlobals: { theme: "dark", frame: "leaf" }, + parameters: { layout: "fullscreen", a11y: { test: "error" }, controls: { expanded: true } } +}; + +export default preview; diff --git a/plugin/esbuild.config.mjs b/plugin/esbuild.config.mjs index 642fcde..5b55b9b 100644 --- a/plugin/esbuild.config.mjs +++ b/plugin/esbuild.config.mjs @@ -10,6 +10,7 @@ const context = await esbuild.context({ logLevel: "info", sourcemap: production ? false : "inline", treeShaking: true, + minify: production, outfile: "main.js" }); diff --git a/plugin/eslint.config.mjs b/plugin/eslint.config.mjs new file mode 100644 index 0000000..7c3d73d --- /dev/null +++ b/plugin/eslint.config.mjs @@ -0,0 +1,62 @@ +import { defineConfig } from "eslint/config"; +import { builtinModules } from "node:module"; +import obsidianmd from "eslint-plugin-obsidianmd"; +import reactHooks from "eslint-plugin-react-hooks"; +import sonarjs from "eslint-plugin-sonarjs"; +import storybook from "eslint-plugin-storybook"; + +export default defineConfig([ + { ignores: ["src/api/generated/**", "main.js", "storybook-static/**"] }, + ...obsidianmd.configs.recommended, + ...storybook.configs["flat/recommended"], + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } }, + plugins: { "react-hooks": reactHooks }, + rules: { + ...reactHooks.configs.recommended.rules, + "obsidianmd/ui/sentence-case": ["error", { brands: ["Topic Stream"], acronyms: ["URL", "RSS", "API", "AI", "HTTP", "HTTPS", "README", "IANA", "LLM"] }], + complexity: ["error", 10], + "max-depth": ["error", 4], + "max-params": ["error", 4], + "max-statements": ["error", 60] + } + }, + { + files: ["src/**/*.{ts,tsx}"], + ignores: ["src/**/*.test.{ts,tsx}", "src/**/*.stories.{ts,tsx}", "src/test/**"], + plugins: { sonarjs }, + rules: { + "max-lines-per-function": ["error", { max: 100, skipBlankLines: true, skipComments: true }], + "sonarjs/cognitive-complexity": ["error", 15], + "no-restricted-imports": ["error", { + paths: [...new Set([...builtinModules, "electron"])].map((name) => ({ name, message: "Use mobile-safe browser or public Obsidian services." })).concat([ + { name: "obsidian", importNames: ["FileSystemAdapter"], message: "Use Vault/FileManager through the mobile-safe host adapter." } + ]), + patterns: ["node:*"] + }], + "no-restricted-globals": ["error", "process", "Buffer", "require", "__dirname", "__filename"] + } + }, + { + files: ["src/features/reader/ReaderBody.tsx", "src/test/storybook/markdown.ts"], + // These DOM boundaries also execute in ordinary browsers without Obsidian's DOM extensions. + rules: { "obsidianmd/prefer-create-el": "off" } + }, + { + files: ["src/shared/advisory-connection.ts"], + // This host-independent state machine owns and cancels every retry on close or realm migration. + // Its network retries do not depend on a DOM window and also run in Node-based owner tests. + rules: { "obsidianmd/prefer-window-timers": "off" } + }, + { + files: ["src/main.ts"], + // Preserve the existing public command ID rather than breaking saved hotkeys. + rules: { "obsidianmd/commands/no-plugin-id-in-command-id": "off" } + }, + { + files: ["src/settings.ts"], + // Declarative settings need Obsidian 1.13; this plugin supports 1.8.7. + rules: { "obsidianmd/settings-tab/prefer-setting-definitions": "off" } + } +]); diff --git a/plugin/manifest.json b/plugin/manifest.json index dc9e9b7..0a43347 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -2,7 +2,7 @@ "id": "topic-stream", "name": "Topic Stream", "version": "0.1.0", - "minAppVersion": "1.8.0", + "minAppVersion": "1.8.7", "description": "A calm, personalized feed and scheduled digests inside Obsidian.", "author": "Topic Stream contributors", "isDesktopOnly": false diff --git a/plugin/package.json b/plugin/package.json index 05e2d6b..008acea 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -4,23 +4,64 @@ "private": true, "type": "module", "scripts": { - "build": "node esbuild.config.mjs production", + "build": "pnpm typecheck && node esbuild.config.mjs production", "dev": "node esbuild.config.mjs", - "check": "tsc --noEmit && vitest run", - "test": "vitest run" + "api:generate": "node scripts/api.mjs", + "api:check": "node scripts/api.mjs --check", + "typecheck": "tsc --noEmit", + "lint": "eslint src --max-warnings 0", + "check": "pnpm api:check && pnpm typecheck && pnpm lint && pnpm test:mobile && pnpm test", + "test:mobile": "node scripts/check-mobile.mjs", + "test": "vitest run --project unit", + "storybook": "storybook dev --port 6006 --no-open --disable-telemetry", + "build:storybook": "storybook build --disable-telemetry", + "test:storybook": "vitest run --project storybook", + "browser:install": "node scripts/install-browser.mjs", + "test:e2e": "pnpm build && wdio run wdio.conf.mjs", + "test:e2e:headless": "node scripts/run-e2e-headless.mjs" }, "dependencies": { + "@hookform/resolvers": "6.0.0", "@tanstack/react-query": "5.104.0", + "dompurify": "3.4.17", + "mdast-util-from-markdown": "2.1.0", "react": "19.3.0", - "react-dom": "19.3.0" + "react-dom": "19.3.0", + "react-hook-form": "7.91.0", + "zod": "4.6.7" }, "devDependencies": { + "@eslint/js": "9.39.4", + "@eslint/json": "0.14.0", + "@storybook/addon-a11y": "10.6.3", + "@storybook/addon-docs": "10.6.3", + "@storybook/addon-vitest": "10.6.3", + "@storybook/react-vite": "10.6.3", "@types/node": "26.6.3", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "@vitest/browser": "5.0.2", + "@vitest/browser-playwright": "5.0.2", + "@wdio/cli": "9.32.0", + "@wdio/globals": "9.31.3", + "@wdio/local-runner": "9.32.0", + "@wdio/mocha-framework": "9.32.0", "esbuild": "0.28.2", - "obsidian": "1.13.1", - "typescript": "7.0.2", - "vitest": "5.0.2" + "eslint": "9.39.4", + "eslint-plugin-obsidianmd": "0.4.2", + "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-sonarjs": "4.2.2", + "eslint-plugin-storybook": "10.6.3", + "mocha": "10.8.2", + "obsidian": "1.8.7", + "openapi-typescript": "7.13.0", + "playwright": "1.64.1", + "storybook": "10.6.3", + "typescript": "5.9.3", + "typescript-eslint": "8.71.0", + "vite": "8.3.5", + "vitest": "5.0.2", + "wdio-obsidian-service": "3.3.0", + "webdriverio": "9.32.0" } } diff --git a/plugin/scripts/api.mjs b/plugin/scripts/api.mjs new file mode 100644 index 0000000..785db5c --- /dev/null +++ b/plugin/scripts/api.mjs @@ -0,0 +1,18 @@ +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import openapiTS, { astToString } from "openapi-typescript"; + +const input = new URL("../../api/openapi/openapi.yaml", import.meta.url); +const output = new URL("../src/api/generated/openapi.ts", import.meta.url); +const source = "/** Generated from api/openapi/openapi.yaml. Do not edit. */\n" + + astToString(await openapiTS(input, { silent: true })); + +if (process.argv.includes("--check")) { + const current = await readFile(output, "utf8").catch(() => ""); + if (current !== source) { + process.stderr.write("Generated API types differ. Run pnpm --dir plugin api:generate.\n"); + process.exitCode = 1; + } +} else { + await mkdir(new URL("../src/api/generated/", import.meta.url), { recursive: true }); + await writeFile(output, source); +} diff --git a/plugin/scripts/check-mobile.mjs b/plugin/scripts/check-mobile.mjs new file mode 100644 index 0000000..8c951e2 --- /dev/null +++ b/plugin/scripts/check-mobile.mjs @@ -0,0 +1,19 @@ +import { build } from "esbuild"; +import { readFile } from "node:fs/promises"; + +const manifest = JSON.parse(await readFile(new URL("../manifest.json", import.meta.url), "utf8")); +if (manifest.isDesktopOnly !== false) throw new Error("The supported mobile boundary requires isDesktopOnly: false."); + +// A browser dependency graph rejects Node/Electron imports, including transitive dependencies. +// Only Obsidian's public host API is external; no desktop module can hide behind externals. +const result = await build({ + entryPoints: ["src/main.ts"], bundle: true, write: false, metafile: true, + platform: "browser", format: "cjs", target: "es2022", minify: true, + external: ["obsidian"], logLevel: "error" +}); +for (const output of Object.values(result.metafile.outputs)) { + for (const dependency of output.imports) { + if (dependency.external && dependency.path !== "obsidian") throw new Error(`Unsupported mobile dependency: ${dependency.path}`); + } +} +console.log(`Mobile dependency boundary passed: ${Object.keys(result.metafile.inputs).length} modules; only public Obsidian API external.`); diff --git a/plugin/scripts/install-browser.mjs b/plugin/scripts/install-browser.mjs new file mode 100644 index 0000000..9af2750 --- /dev/null +++ b/plugin/scripts/install-browser.mjs @@ -0,0 +1,9 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const result = spawnSync("pnpm", ["exec", "playwright", "install", "chromium"], { + stdio: "inherit", + env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: fileURLToPath(new URL("../../.artifacts/playwright", import.meta.url)) } +}); +if (result.error) process.stderr.write(`${result.error.message}\n`); +process.exit(result.status ?? 1); diff --git a/plugin/scripts/run-e2e-headless.mjs b/plugin/scripts/run-e2e-headless.mjs new file mode 100644 index 0000000..b0861c4 --- /dev/null +++ b/plugin/scripts/run-e2e-headless.mjs @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { mkdirSync } from "node:fs"; + +const source = fileURLToPath(new URL("../../", import.meta.url)); +const logs = fileURLToPath(new URL("../../.artifacts/obsidian/logs/", import.meta.url)); +mkdirSync(logs, { recursive: true }); +const script = ` +set -euo pipefail +tar -C /source --exclude=.git --exclude=node_modules --exclude=.artifacts \\ + --exclude=.scratch --exclude=.pnpm-store --exclude=storybook-static \\ + --exclude=plugin/main.js --exclude=plugin/main.js.map -cf - . | tar -C /work -xf - +corepack enable +pnpm install --frozen-lockfile +xvfb-run -a pnpm --dir plugin test:e2e +`; +const args = [ + "run", "--rm", "--init", "--ipc=host", "-e", "CI=true", + "-v", `${source}:/source:ro`, + "-v", "topic-stream-e2e-node-modules:/work/node_modules", + "-v", "topic-stream-e2e-obsidian-cache:/work/.artifacts/obsidian/cache", + "-v", `${logs}:/work/.artifacts/obsidian/logs`, + "-w", "/work" +]; +for (const name of ["OBSIDIAN_TEST_VERSIONS", "OBSIDIAN_APP_VERSION", "OBSIDIAN_INSTALLER_VERSION", "SSE_PROBE_URL"]) { + if (process.env[name] !== undefined) args.push("-e", name); +} +args.push("mcr.microsoft.com/playwright:v1.64.1-noble", "bash", "-lc", script); +const result = spawnSync("docker", args, { stdio: "inherit" }); +if (result.error) process.stderr.write(`Could not start Docker: ${result.error.message}\n`); +process.exit(result.status ?? 1); diff --git a/plugin/src/api/archive-jobs-client.test.ts b/plugin/src/api/archive-jobs-client.test.ts new file mode 100644 index 0000000..b133633 --- /dev/null +++ b/plugin/src/api/archive-jobs-client.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { TopicStreamClient } from "./client"; +import type { HttpTransport } from "./transport"; +import { fixtureId } from "../test/storybook/fixtures"; + +describe("Digest archive and Task Center API", () => { + it("reads opaque Job pages through the public status filter without undefined query values", async () => { + const transport = vi.fn().mockResolvedValue({ status: 200, json: { items: [], nextCursor: null } }); + const client = new TopicStreamClient("https://backend.example/", transport); + await expect(client.listJobs({ status: "retry_wait", limit: 30, cursor: "opaque+/=next" })).resolves.toEqual({ items: [], nextCursor: null }); + expect(transport).toHaveBeenCalledWith({ method: "GET", url: "https://backend.example/api/v1/jobs?status=retry_wait&limit=30&cursor=opaque%2B%2F%3Dnext" }); + }); + it("reads saved Issues scoped to a Definition with an opaque continuation", async () => { + const transport = vi.fn().mockResolvedValue({ status: 200, json: { items: [], nextCursor: null } }); + const client = new TopicStreamClient("https://backend.example", transport); + await client.listDigestIssues({ digestId: fixtureId(900), limit: 20, cursor: "a+/=" }); + expect(transport).toHaveBeenCalledWith({ method: "GET", url: `https://backend.example/api/v1/digest-issues?digestId=${fixtureId(900)}&limit=20&cursor=a%2B%2F%3D` }); + }); +}); diff --git a/plugin/src/api/client-errors.test.ts b/plugin/src/api/client-errors.test.ts index 6adb7aa..233998f 100644 --- a/plugin/src/api/client-errors.test.ts +++ b/plugin/src/api/client-errors.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HttpRequest, HttpResponse } from "./transport"; -const { requestUrl } = vi.hoisted(() => ({ requestUrl: vi.fn() })); +const { requestUrl } = vi.hoisted(() => ({ requestUrl: vi.fn<(options: HttpRequest & { throw: boolean }) => Promise>() })); vi.mock("obsidian", () => ({ requestUrl })); import { TopicStreamClient } from "./client"; describe("HTTP error boundary", () => { - const client = new TopicStreamClient("http://localhost:7331"); + const client = new TopicStreamClient("http://localhost:7331", (options) => requestUrl({ ...options, throw: false })); beforeEach(() => { requestUrl.mockReset(); }); it("keeps stable codes and all field errors while showing the safe detail", async () => { diff --git a/plugin/src/api/client.ts b/plugin/src/api/client.ts index 07c0005..48376a2 100644 --- a/plugin/src/api/client.ts +++ b/plugin/src/api/client.ts @@ -1,14 +1,14 @@ -import { requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian"; - import { httpError } from "./errors"; -import type { Feedback, FeedPage, JobAccepted, Source, SourceInput, Topic, TopicInput } from "./types"; +import type { Feedback, FeedPage, ItemImageDownload, JobAccepted, PathParameters, QueryParameters, RequestBody, ResponseBody, Source, SourceInput, Topic, TopicInput } from "./contracts"; +import { isCancellation, waitForResponse, type HttpRequest, type HttpResponse, type HttpTransport } from "./transport"; import { buildFeedUrl, type FeedPageRequest } from "./url"; +import type { Schema } from "./contracts"; export type { FeedPageRequest } from "./url"; export class TopicStreamClient { - public constructor(private readonly baseUrl: string) {} + public constructor(private readonly baseUrl: string, private readonly transport: HttpTransport, private readonly signal?: AbortSignal) {} public async getFeed(request: FeedPageRequest): Promise { const response = await this.request({ @@ -18,19 +18,72 @@ export class TopicStreamClient { return response; } - public async setFeedback(itemId: string, sentiment: "like" | "dislike", comment: string | null): Promise { + public refreshFeed(input: RequestBody<"refreshFeed">): Promise { + return this.request({ url: new URL("/api/v1/feed/refresh", normalizedBaseUrl(this.baseUrl)).toString(), method: "POST", contentType: "application/json", body: JSON.stringify(input) }, 200); + } + + public searchItems(input: QueryParameters<"searchItems">): Promise> { + const url = new URL("/api/v1/search", normalizedBaseUrl(this.baseUrl)); + for (const [name, value] of Object.entries(input)) if (value !== undefined) url.searchParams.set(name, String(value)); + return this.request({ url: url.toString(), method: "GET" }, 200); + } + + public getItem(itemId: string): Promise> { + return this.request({ url: new URL(`/api/v1/items/${encodeURIComponent(itemId)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + } + + public getItemSummary(itemId: string, sourceVersion: string): Promise> { + const url = new URL(`/api/v1/items/${encodeURIComponent(itemId)}/ai-summary`, normalizedBaseUrl(this.baseUrl)); + url.searchParams.set("sourceVersion", sourceVersion); + return this.request({ url: url.toString(), method: "GET" }, 200); + } + public requestItemSummary(itemId: string): Promise { + return this.request({ url: new URL(`/api/v1/items/${encodeURIComponent(itemId)}/ai-summary`, normalizedBaseUrl(this.baseUrl)).toString(), method: "POST", contentType: "application/json", body: "{}" }, 202); + } + public requestDigestItemSummary(issueId: string, itemId: string): Promise { + return this.request({ url: new URL(`/api/v1/digest-issues/${encodeURIComponent(issueId)}/items/${encodeURIComponent(itemId)}/ai-summary`, normalizedBaseUrl(this.baseUrl)).toString(), method: "POST", contentType: "application/json", body: "{}" }, 202); + } + public getDigestIssue(issueId: string): Promise> { + return this.request({ url: new URL(`/api/v1/digest-issues/${encodeURIComponent(issueId)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + } + public listDigestIssues(input: QueryParameters<"listDigestIssues">): Promise> { + const url = new URL("/api/v1/digest-issues", normalizedBaseUrl(this.baseUrl)); + for (const [name, value] of Object.entries(input)) if (value !== undefined) url.searchParams.set(name, String(value)); + return this.request({ url: url.toString(), method: "GET" }, 200); + } + + public async downloadItemImage(itemId: string, imageId: string): Promise { + const response = await this.response({ url: new URL(`/api/v1/items/${encodeURIComponent(itemId)}/images/${encodeURIComponent(imageId)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + const bytes = response.arrayBuffer; + const contentType = Object.entries(response.headers ?? {}).find(([name]) => name.toLowerCase() === "content-type")?.[1].split(";")[0]?.trim().toLowerCase(); + if (!bytes || !contentType?.startsWith("image/") || bytes.byteLength === 0 || bytes.byteLength > 10 * 1024 * 1024) throw new Error("Topic Stream returned an invalid image."); + return { bytes, contentType }; + } + + public setReadingState(itemId: string, state: RequestBody<"setItemReadingState">["state"]): Promise { + return this.request({ url: new URL(`/api/v1/items/${encodeURIComponent(itemId)}/reading-state`, normalizedBaseUrl(this.baseUrl)).toString(), method: "PUT", contentType: "application/json", body: JSON.stringify({ state } satisfies RequestBody<"setItemReadingState">) }, 204); + } + public setVisibility(itemId: string, visibility: RequestBody<"setItemVisibility">["visibility"]): Promise { + return this.request({ url: new URL(`/api/v1/items/${encodeURIComponent(itemId)}/visibility`, normalizedBaseUrl(this.baseUrl)).toString(), method: "PUT", contentType: "application/json", body: JSON.stringify({ visibility } satisfies RequestBody<"setItemVisibility">) }, 204); + } + public setTopicCorrection(itemId: string, input: RequestBody<"setItemTopicCorrection">): Promise { + return this.request({ url: new URL(`/api/v1/items/${encodeURIComponent(itemId)}/topic-corrections`, normalizedBaseUrl(this.baseUrl)).toString(), method: "PUT", contentType: "application/json", body: JSON.stringify(input) }, 204); + } + + public async setFeedback(itemId: PathParameters<"setItemFeedback">["itemId"], sentiment: RequestBody<"setItemFeedback">["sentiment"], comment: RequestBody<"setItemFeedback">["comment"]): Promise { const url = new URL(`/api/v1/items/${encodeURIComponent(itemId)}/feedback`, normalizedBaseUrl(this.baseUrl)); + const input: RequestBody<"setItemFeedback"> = { sentiment, comment }; const response = await this.request({ url: url.toString(), method: "PUT", contentType: "application/json", - body: JSON.stringify({ sentiment, comment }) + body: JSON.stringify(input) }, 200); return response; } public async listSources(): Promise { - const response = await this.request<{ items: Source[] }>({ + const response = await this.request>({ url: new URL("/api/v1/sources", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); @@ -56,7 +109,7 @@ export class TopicStreamClient { } public async listTopics(): Promise { - const response = await this.request<{ items: Topic[] }>({ + const response = await this.request>({ url: new URL("/api/v1/topics", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); @@ -73,24 +126,110 @@ export class TopicStreamClient { return response; } - private async request(options: RequestUrlParam, expectedStatus: number): Promise { - let response: RequestUrlResponse; + public getPreferenceProfile(): Promise> { + return this.request({ url: new URL("/api/v1/preference-profile", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + } + public updatePreferenceProfile(input: RequestBody<"updatePreferenceProfile">, version: number): Promise> { + return this.request({ + url: new URL("/api/v1/preference-profile", normalizedBaseUrl(this.baseUrl)).toString(), method: "PATCH", + headers: { "If-Match": `"${version}"` }, contentType: "application/json", body: JSON.stringify(input) + }, 200); + } + public syncSource(id: string): Promise { + return this.request({ url: new URL(`/api/v1/sources/${encodeURIComponent(id)}/sync`, normalizedBaseUrl(this.baseUrl)).toString(), method: "POST" }, 202); + } + public getJob(id: string): Promise> { + return this.request({ url: new URL(`/api/v1/jobs/${encodeURIComponent(id)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + } + public listJobs(input: QueryParameters<"listJobs">): Promise> { + const url = new URL("/api/v1/jobs", normalizedBaseUrl(this.baseUrl)); + for (const [name, value] of Object.entries(input)) if (value !== undefined) url.searchParams.set(name, String(value)); + return this.request({ url: url.toString(), method: "GET" }, 200); + } + public updateSource(id: string, input: RequestBody<"updateSource">): Promise { + return this.request({ url: new URL(`/api/v1/sources/${encodeURIComponent(id)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "PATCH", contentType: "application/json", body: JSON.stringify(input) }, 200); + } + public deleteSource(id: string): Promise { + return this.request({ url: new URL(`/api/v1/sources/${encodeURIComponent(id)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "DELETE" }, 204); + } + public updateTopic(id: string, input: RequestBody<"updateTopic">): Promise { + return this.request({ url: new URL(`/api/v1/topics/${encodeURIComponent(id)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "PATCH", contentType: "application/json", body: JSON.stringify(input) }, 200); + } + public deleteTopic(id: string): Promise { + return this.request({ url: new URL(`/api/v1/topics/${encodeURIComponent(id)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "DELETE" }, 204); + } + + public async listDigests(): Promise[]> { + const value = await this.request>({ url: new URL("/api/v1/digests", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + return value.items; + } + public createDigest(input: RequestBody<"createDigest">): Promise> { + return this.request({ url: new URL("/api/v1/digests", normalizedBaseUrl(this.baseUrl)).toString(), method: "POST", contentType: "application/json", body: JSON.stringify(input) }, 201); + } + public updateDigest(id: string, input: RequestBody<"updateDigest">, version: number): Promise> { + return this.request({ url: new URL(`/api/v1/digests/${encodeURIComponent(id)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "PATCH", headers: { "If-Match": `"${version}"` }, contentType: "application/json", body: JSON.stringify(input) }, 200); + } + public deleteDigest(id: string, version: number): Promise { + return this.request({ url: new URL(`/api/v1/digests/${encodeURIComponent(id)}`, normalizedBaseUrl(this.baseUrl)).toString(), method: "DELETE", headers: { "If-Match": `"${version}"` } }, 204); + } + public generateDigest(id: string, requestId: string): Promise { + return this.request({ url: new URL(`/api/v1/digests/${encodeURIComponent(id)}/generate`, normalizedBaseUrl(this.baseUrl)).toString(), method: "POST", headers: { "Idempotency-Key": requestId } }, 202); + } + public getSystemStatus(): Promise> { + return this.request({ url: new URL("/api/v1/system/status", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + } + + public getWorkspaceSettings(): Promise> { + return this.request({ url: new URL("/api/v1/workspace-settings", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + } + public updateWorkspaceSettings(input: RequestBody<"updateWorkspaceSettings">, version: number): Promise> { + return this.request({ url: new URL("/api/v1/workspace-settings", normalizedBaseUrl(this.baseUrl)).toString(), method: "PATCH", headers: { "If-Match": `"${version}"` }, contentType: "application/json", body: JSON.stringify(input) }, 200); + } + + public async listEmbeddingModelOptions(): Promise[]> { + const value = await this.request>({ url: new URL("/api/v1/embedding-model-options", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + return value.items; + } + public getRetrievalStatus(): Promise> { + return this.request({ url: new URL("/api/v1/retrieval-status", normalizedBaseUrl(this.baseUrl)).toString(), method: "GET" }, 200); + } + + public cancelJob(id: string): Promise> { + return this.request({ url: new URL(`/api/v1/jobs/${encodeURIComponent(id)}/cancel`, normalizedBaseUrl(this.baseUrl)).toString(), method: "POST" }, 202); + } + public retryJob(id: string): Promise { + return this.request({ url: new URL(`/api/v1/jobs/${encodeURIComponent(id)}/retry`, normalizedBaseUrl(this.baseUrl)).toString(), method: "POST" }, 202); + } + + private async request(options: HttpRequest, expectedStatus: number): Promise { + const response = await this.response(options, expectedStatus); + if (expectedStatus === 204) return undefined as T; + return responseJson(response); + } + + private async response(options: HttpRequest, expectedStatus: number): Promise { + let response; try { - response = await requestUrl({ ...options, throw: false }); + response = await waitForResponse(this.transport, options, this.signal); + if (this.signal?.aborted) throw new DOMException("Connection closed", "AbortError"); } catch (error) { - if (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError") throw error; + if (isCancellation(error)) throw error; throw new Error("Could not connect to Topic Stream."); } if (response.status !== expectedStatus) { - throw httpError(response.status, response.text); - } - try { - const value: unknown = response.json; - if (value === undefined || value === null) throw new Error("Missing response data"); - return value as T; - } catch { - throw new Error("Topic Stream returned an invalid response."); + throw httpError(response.status, response.text ?? ""); } + return response; + } +} + +function responseJson(response: HttpResponse): T { + try { + const value: unknown = response.json; + if (value === undefined || value === null) throw new Error("Missing response data"); + return value as T; + } catch { + throw new Error("Topic Stream returned an invalid response."); } } diff --git a/plugin/src/api/connection.test.ts b/plugin/src/api/connection.test.ts new file mode 100644 index 0000000..eddfc4c --- /dev/null +++ b/plugin/src/api/connection.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("obsidian", () => ({ requestUrl: vi.fn() })); + +import { TopicStreamClient } from "./client"; + +describe("connection-owned HTTP consumers", () => { + it("abandons an old connection without delivering its late HTTP response", async () => { + let complete!: (value: { status: number; text: string; json: unknown }) => void; + const transport = vi.fn(() => new Promise<{ status: number; text: string; json: unknown }>((resolve) => { complete = resolve; })); + const connection = new AbortController(); + const client = new TopicStreamClient("http://localhost:7331", transport, connection.signal); + const request = client.listSources(); + const result = expect(request).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(1)); + connection.abort(); + await result; + complete({ status: 200, text: '{"items":[]}', json: { items: [] } }); + expect(transport).toHaveBeenCalledTimes(1); + await expect(client.listSources()).rejects.toMatchObject({ name: "AbortError" }); + expect(transport).toHaveBeenCalledTimes(1); + }); + + it("sends the current contract's typed Source config once", async () => { + const input = { name: "Research", config: { kind: "rss" as const, url: "https://example.org/rss", allowPrivateNetwork: false }, enabled: true, pollIntervalMinutes: 60 }; + const transport = vi.fn().mockResolvedValue({ status: 201, json: { id: "source", ...input } }); + const client = new TopicStreamClient("http://localhost:7331", transport); + await expect(client.createSource(input)).resolves.toMatchObject(input); + expect(transport).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", body: JSON.stringify(input) })); + expect(transport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugin/src/api/contracts.ts b/plugin/src/api/contracts.ts new file mode 100644 index 0000000..e08b6cf --- /dev/null +++ b/plugin/src/api/contracts.ts @@ -0,0 +1,25 @@ +import type { components, operations } from "./generated/openapi"; + +export type Schema = components["schemas"][Name]; +export type ResponseBody = + operations[Operation]["responses"][Status] extends { content: { "application/json": infer Body } } ? Body : never; +export type RequestBody = + operations[Operation] extends { requestBody: { content: { "application/json": infer Body } } } ? Body : never; +export type PathParameters = NonNullable; +export type QueryParameters = NonNullable; + +export type Feedback = ResponseBody<"setItemFeedback", 200>; +export type FeedPage = ResponseBody<"getFeed", 200>; +export type FeedEntry = FeedPage["items"][number]; +export type Item = Schema<"Item">; +export type ItemKind = Schema<"ItemKind">; +export type TopicMode = Schema<"TopicMode">; +export type Source = ResponseBody<"createSource", 201>; +export type SourceInput = RequestBody<"createSource">; +export type Topic = ResponseBody<"createTopic", 201>; +export type TopicInput = RequestBody<"createTopic">; +export type JobAccepted = ResponseBody<"syncAllSources", 202>; +export type PublicProblem = Schema<"Problem"> | Schema<"ProjectionPendingProblem">; + +/** Binary image response plus its validated media type, outside the API's JSON schemas. */ +export interface ItemImageDownload { bytes: ArrayBuffer; contentType: string; } diff --git a/plugin/src/api/errors.ts b/plugin/src/api/errors.ts index a5546d4..d38efa8 100644 --- a/plugin/src/api/errors.ts +++ b/plugin/src/api/errors.ts @@ -1,7 +1,6 @@ -export interface FieldIssue { - field: string; - message: string; -} +import type { Schema } from "./contracts"; + +export type FieldIssue = NonNullable["errors"]>[number]; interface PublicProblem { type: string; @@ -22,13 +21,17 @@ export class ApiError extends Error { public readonly projectionRevision: string | null; public constructor(public readonly status: number, problem: PublicProblem | null) { - super(problem?.detail ?? problem?.title ?? `Topic Stream could not complete the request (HTTP ${status}).`); + const value = problem ?? { + type: null, code: null, issues: [], canonicalCommitted: false, projectionRevision: null, + detail: null, title: `Topic Stream could not complete the request (HTTP ${status}).` + }; + super(value.detail ?? value.title); this.name = "ApiError"; - this.type = problem?.type ?? null; - this.code = problem?.code ?? null; - this.issues = problem?.issues ?? []; - this.canonicalCommitted = problem?.canonicalCommitted ?? false; - this.projectionRevision = problem?.projectionRevision ?? null; + this.type = value.type; + this.code = value.code; + this.issues = value.issues; + this.canonicalCommitted = value.canonicalCommitted; + this.projectionRevision = value.projectionRevision; } } @@ -42,14 +45,8 @@ export function httpError(status: number, text: string): ApiError { } function parseProblem(value: unknown, status: number): PublicProblem | null { - if (!isRecord(value) || value.status !== status || typeof value.type !== "string" || value.type.trim() === "" || - typeof value.title !== "string" || value.title.trim() === "") { - return null; - } - if ((value.detail !== undefined && typeof value.detail !== "string") || - (value.code !== undefined && typeof value.code !== "string")) { - return null; - } + if (!isRecord(value) || !hasProblemIdentity(value, status)) return null; + if (!['detail', 'code'].every((key) => value[key] === undefined || typeof value[key] === "string")) return null; const issues = parseIssues(value.errors); if (issues === null) return null; return { @@ -63,6 +60,11 @@ function parseProblem(value: unknown, status: number): PublicProblem | null { }; } +function hasProblemIdentity(value: Record, status: number): value is Record & { type: string; title: string } { + return value.status === status && typeof value.type === "string" && value.type.trim() !== "" && + typeof value.title === "string" && value.title.trim() !== ""; +} + function parseIssues(value: unknown): FieldIssue[] | null { if (value === undefined) return []; if (!Array.isArray(value)) return null; diff --git a/plugin/src/api/feed-client.test.ts b/plugin/src/api/feed-client.test.ts new file mode 100644 index 0000000..2167f7a --- /dev/null +++ b/plugin/src/api/feed-client.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { TopicStreamClient } from "./client"; +import { FIXTURE_TIME, fixtureId } from "../test/storybook/fixtures"; + +describe("Feed publication API", () => { + it("keeps scoped publication separate from opaque snapshot reads", async () => { + const page = { items: [], nextCursor: null, snapshot: "opaque-snapshot", generatedAt: FIXTURE_TIME }; + const transport = vi.fn().mockResolvedValue({ status: 200, json: page }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.refreshFeed({ limit: 25 })).resolves.toEqual(page); + await client.refreshFeed({ limit: 25, topicId: fixtureId(201) }); + await client.getFeed({ limit: 25, topicId: fixtureId(201), cursor: "older/cursor", snapshot: "opaque-snapshot" }); + expect(transport.mock.calls).toEqual([ + [{ url: "https://backend.example/api/v1/feed/refresh", method: "POST", contentType: "application/json", body: '{"limit":25}' }], + [{ url: "https://backend.example/api/v1/feed/refresh", method: "POST", contentType: "application/json", body: JSON.stringify({ limit: 25, topicId: fixtureId(201) }) }], + [{ url: `https://backend.example/api/v1/feed?limit=25&topicId=${fixtureId(201)}&cursor=older%2Fcursor&snapshot=opaque-snapshot`, method: "GET" }] + ]); + }); + it("does not retry an uncertain publication response", async () => { + const transport = vi.fn().mockRejectedValue(new Error("Connection lost after send")); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.refreshFeed({ limit: 25 })).rejects.toThrow("Could not connect"); + expect(transport).toHaveBeenCalledTimes(1); + }); + it("writes reading, global visibility and scoped membership through their own operations", async () => { + const transport = vi.fn().mockResolvedValue({ status: 204, text: "" }); + const client = new TopicStreamClient("https://backend.example", transport); + await client.setReadingState(fixtureId(300), "read"); + await client.setVisibility(fixtureId(300), "hidden"); + await client.setTopicCorrection(fixtureId(300), { topicId: fixtureId(201), membership: "exclude" }); + expect(transport.mock.calls).toEqual([ + [{ url: `https://backend.example/api/v1/items/${fixtureId(300)}/reading-state`, method: "PUT", contentType: "application/json", body: '{"state":"read"}' }], + [{ url: `https://backend.example/api/v1/items/${fixtureId(300)}/visibility`, method: "PUT", contentType: "application/json", body: '{"visibility":"hidden"}' }], + [{ url: `https://backend.example/api/v1/items/${fixtureId(300)}/topic-corrections`, method: "PUT", contentType: "application/json", body: JSON.stringify({ topicId: fixtureId(201), membership: "exclude" }) }] + ]); + }); +}); diff --git a/plugin/src/api/generated/openapi.ts b/plugin/src/api/generated/openapi.ts new file mode 100644 index 0000000..84aafd3 --- /dev/null +++ b/plugin/src/api/generated/openapi.ts @@ -0,0 +1,2575 @@ +/** Generated from api/openapi/openapi.yaml. Do not edit. */ +export interface paths { + "/api/v1/system/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Inspect daemon readiness */ + get: operations["getSystemStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Stream advisory invalidation events */ + get: operations["streamEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/embedding-models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List registered embedding models */ + get: operations["listEmbeddingModels"]; + put?: never; + /** Register and initialize an embedding model */ + post: operations["createEmbeddingModel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/embedding-models/{modelId}": { + parameters: { + query?: never; + header?: never; + path: { + modelId: components["parameters"]["model-id"]; + }; + cookie?: never; + }; + /** Get an embedding model */ + get: operations["getEmbeddingModel"]; + /** Update mutable embedding model fields */ + put: operations["updateEmbeddingModel"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/embedding-models/{modelId}/initialize": { + parameters: { + query?: never; + header?: never; + path: { + modelId: components["parameters"]["model-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Retry embedding model initialization */ + post: operations["initializeEmbeddingModel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/embedding-model-options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List embedding models visible to workspace settings */ + get: operations["listEmbeddingModelOptions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/retrieval-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Inspect desired, serving, and transition retrieval state */ + get: operations["getRetrievalStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workspace-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read non-secret workspace settings */ + get: operations["getWorkspaceSettings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update non-secret workspace settings */ + patch: operations["updateWorkspaceSettings"]; + trace?: never; + }; + "/api/v1/preference-profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read current preference guidance */ + get: operations["getPreferenceProfile"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update preference guidance */ + patch: operations["updatePreferenceProfile"]; + trace?: never; + }; + "/api/v1/sources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listSources"]; + put?: never; + post: operations["createSource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sources/sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["syncAllSources"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sources/{sourceId}": { + parameters: { + query?: never; + header?: never; + path: { + sourceId: components["parameters"]["source-id"]; + }; + cookie?: never; + }; + get: operations["getSource"]; + put?: never; + post?: never; + delete: operations["deleteSource"]; + options?: never; + head?: never; + patch: operations["updateSource"]; + trace?: never; + }; + "/api/v1/sources/{sourceId}/sync": { + parameters: { + query?: never; + header?: never; + path: { + sourceId: components["parameters"]["source-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["syncSource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listTopics"]; + put?: never; + post: operations["createTopic"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics/{topicId}": { + parameters: { + query?: never; + header?: never; + path: { + topicId: components["parameters"]["topic-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteTopic"]; + options?: never; + head?: never; + patch: operations["updateTopic"]; + trace?: never; + }; + "/api/v1/feed": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read saved Feed publications without ranking or publishing */ + get: operations["getFeed"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/feed/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Publish a bounded batch from ready candidates and read the latest saved page + * @description Does not wait for candidate preparation. Published Items never publish again in this Workspace. Automatic network retries are disabled. + */ + post: operations["refreshFeed"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["searchItems"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/items/{itemId}": { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + get: operations["getItem"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/items/{itemId}/ai-summary": { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + /** Read AI annotation state without starting generation */ + get: operations["getItemSummary"]; + put?: never; + /** Explicitly request asynchronous AI summary of current owned content */ + post: operations["requestItemSummary"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digest-issues/{issueId}/items/{itemId}/ai-summary": { + parameters: { + query?: never; + header?: never; + path: { + issueId: components["parameters"]["issue-id"]; + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Explicitly request AI summary of the exact saved Item snapshot */ + post: operations["requestDigestItemSummary"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/items/{itemId}/feedback": { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + get?: never; + put: operations["setItemFeedback"]; + post?: never; + delete: operations["clearItemFeedback"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/items/{itemId}/reading-state": { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + get?: never; + put: operations["setItemReadingState"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/items/{itemId}/visibility": { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + get?: never; + put: operations["setItemVisibility"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/items/{itemId}/topic-corrections": { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + get?: never; + put: operations["setItemTopicCorrection"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/items/{itemId}/images/{imageId}": { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + imageId: components["parameters"]["image-id"]; + }; + cookie?: never; + }; + /** Download an image already associated with an item */ + get: operations["downloadItemImage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listDigests"]; + put?: never; + post: operations["createDigest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digests/{digestId}": { + parameters: { + query?: never; + header?: never; + path: { + digestId: components["parameters"]["digest-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteDigest"]; + options?: never; + head?: never; + patch: operations["updateDigest"]; + trace?: never; + }; + "/api/v1/digests/{digestId}/generate": { + parameters: { + query?: never; + header?: never; + path: { + digestId: components["parameters"]["digest-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["generateDigest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digest-issues": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listDigestIssues"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digest-issues/{issueId}": { + parameters: { + query?: never; + header?: never; + path: { + issueId: components["parameters"]["issue-id"]; + }; + cookie?: never; + }; + get: operations["getDigestIssue"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/jobs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listJobs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/jobs/{jobId}": { + parameters: { + query?: never; + header?: never; + path: { + jobId: components["parameters"]["job-id"]; + }; + cookie?: never; + }; + get: operations["getJob"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/jobs/{jobId}/retry": { + parameters: { + query?: never; + header?: never; + path: { + jobId: components["parameters"]["job-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["retryJob"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/jobs/{jobId}/cancel": { + parameters: { + query?: never; + header?: never; + path: { + jobId: components["parameters"]["job-id"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Request cancellation of a queued or running job */ + post: operations["cancelJob"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportWorkspace"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["importWorkspace"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Problem: { + /** + * Format: uri-reference + * @description Stable problem type identifier. `about:blank` is used only when the HTTP status fully describes the problem. + */ + type: string; + /** @description Stable summary associated with the problem type. */ + title: string; + status: number; + /** @description Safe English explanation approved for display to the user. Never a raw internal error string. */ + detail?: string; + /** Format: uri-reference */ + instance?: string; + /** @description Stable snake_case application error code. Unexpected application failures use internal_error with HTTP 500; clients must not infer a code from title or detail. */ + code?: string; + errors?: { + /** @description Application field path, or the JSON pointer or parameter name supplied by OpenAPI validation. */ + field: string; + /** @description Safe English explanation of the violated constraint, without the submitted value. */ + message: string; + }[]; + }; + ProjectionPendingProblem: { + /** + * Format: uri-reference + * @constant + */ + type: "urn:topic-stream:problem:projection-pending"; + /** @constant */ + title: "Retrieval projection pending"; + /** @constant */ + status: 503; + detail?: string; + /** @constant */ + code: "projection_pending"; + /** @constant */ + canonicalCommitted: true; + projectionRevision: components["schemas"]["UUID"]; + }; + SystemStatus: { + /** @enum {string} */ + status: "ready" | "degraded" | "unavailable"; + version: string; + workspaceId: components["schemas"]["UUID"]; + components: components["schemas"]["ComponentStatus"][]; + now: components["schemas"]["Timestamp"]; + }; + WorkspaceSettings: { + version: number; + embeddingModelId: components["schemas"]["UUID"]; + generation: components["schemas"]["GenerationSettings"]; + summary: components["schemas"]["SummarySettings"]; + instructions: components["schemas"]["TaskInstructions"]; + retention: components["schemas"]["RetentionSettings"]; + retrieval: components["schemas"]["RetrievalSettings"]; + updatedAt: components["schemas"]["Timestamp"]; + }; + WorkspaceSettingsPatch: { + embeddingModelId?: components["schemas"]["UUID"]; + generation?: components["schemas"]["GenerationSettings"]; + summary?: components["schemas"]["SummarySettings"]; + instructions?: components["schemas"]["TaskInstructions"]; + retention?: components["schemas"]["RetentionSettings"]; + retrieval?: components["schemas"]["RetrievalSettings"]; + }; + EmbeddingModelDescriptor: { + name: string; + /** Format: uri */ + endpoint: string; + providerModel: string; + dimensions: number; + }; + EmbeddingModelCreate: components["schemas"]["EmbeddingModelDescriptor"]; + EmbeddingModelUpdate: { + name: string; + /** Format: uri */ + endpoint: string; + enabled: boolean; + }; + EmbeddingModel: { + id: components["schemas"]["UUID"]; + name: string; + /** Format: uri */ + endpoint: string; + providerModel: string; + dimensions: number; + enabled: boolean; + /** @enum {string} */ + initializationStatus: "initializing" | "ready" | "failed"; + failureReason: string | null; + version: number; + createdAt: components["schemas"]["Timestamp"]; + updatedAt: components["schemas"]["Timestamp"]; + }; + EmbeddingModelList: { + items: components["schemas"]["EmbeddingModel"][]; + }; + EmbeddingModelOption: { + model: components["schemas"]["EmbeddingModel"]; + selectable: boolean; + }; + EmbeddingModelOptionList: { + items: components["schemas"]["EmbeddingModelOption"][]; + }; + RetrievalStatus: { + latestTransition?: components["schemas"]["ModelTransition"]; + desiredModelId: components["schemas"]["UUID"]; + servingModelId: components["schemas"]["UUID"] | null; + transitionJobId: components["schemas"]["UUID"] | null; + }; + /** @description Verified model-selection operation. Waiting has no admitted Job; retry eligibility is rechecked atomically on admission. */ + ModelTransition: { + modelId: components["schemas"]["UUID"]; + /** Format: int64 */ + selectionGeneration: number; + jobId: components["schemas"]["UUID"] | null; + /** @enum {string} */ + status: "waiting" | "queued" | "running" | "retry_wait" | "succeeded" | "dead" | "cancelled"; + isCurrent: boolean; + canRetry: boolean; + }; + GenerationSettings: { + enabled: boolean; + endpoint: string | null; + summaryModel: string | null; + rerankModel: string | null; + }; + SummarySettings: { + language: string; + maxInputChars: number; + maxSummaryCharacters: number; + }; + TaskInstructions: { + summaryChunk: string; + summaryReduce: string; + summaryFinal: string; + digestRerank: string; + }; + RetentionSettings: { + fullContentDays: number | null; + }; + RetrievalSettings: { + candidateLimit: number; + rerankLimit: number; + rrfK: number; + recencyHalfLifeDays: number; + recencyMaxBoost: number; + feedSourceCap: number; + digestSourceCap: number; + digestMmrLambda: number; + llmRerankEnabled: boolean; + digestExplorationEnabled: boolean; + }; + PreferenceProfile: { + prefer: string; + avoid: string; + revision: number; + version: number; + updatedAt: components["schemas"]["Timestamp"]; + }; + PreferenceProfilePatch: { + prefer?: string; + avoid?: string; + }; + Source: { + id: components["schemas"]["UUID"]; + name: string; + config: components["schemas"]["SourceConfig"]; + enabled: boolean; + pollIntervalMinutes: number; + lastSyncedAt: components["schemas"]["Timestamp"] | null; + createdAt: components["schemas"]["Timestamp"]; + updatedAt: components["schemas"]["Timestamp"]; + }; + Topic: { + id: components["schemas"]["UUID"]; + name: string; + sourceIds: components["schemas"]["UUID"][]; + itemKinds: components["schemas"]["ItemKind"][]; + include: string; + exclude: string; + mode: components["schemas"]["TopicMode"]; + enabled: boolean; + revision: number; + createdAt: components["schemas"]["Timestamp"]; + updatedAt: components["schemas"]["Timestamp"]; + }; + Item: { + id: components["schemas"]["UUID"]; + sourceIds: components["schemas"]["UUID"][]; + kind: components["schemas"]["ItemKind"]; + details: components["schemas"]["ItemDetails"]; + title: string; + /** Format: uri */ + url: string; + /** Format: uri */ + canonicalUrl: string; + excerpt: string; + contentMarkdown: string; + contentOrigin: components["schemas"]["ContentOrigin"]; + /** @description Only proven article body or README has an available generation control. */ + summarySupported: boolean; + summarySourceVersion: components["schemas"]["SummarySourceVersion"]; + authors: string[]; + publishedAt: components["schemas"]["Timestamp"] | null; + discoveredAt: components["schemas"]["Timestamp"]; + topicIds: components["schemas"]["UUID"][]; + readingState: components["schemas"]["ReadingState"]; + visibility: components["schemas"]["ItemVisibility"]; + feedback: components["schemas"]["Feedback"] | null; + images: components["schemas"]["ItemImage"][]; + }; + ItemImage: { + id: components["schemas"]["UUID"]; + /** Format: uri */ + url: string; + altText: string | null; + }; + FeedRefreshInput: { + topicId?: components["schemas"]["UUID"]; + /** @default 25 */ + limit: number; + }; + FeedPage: { + items: components["schemas"]["FeedEntry"][]; + nextCursor: string | null; + snapshot: string; + generatedAt: components["schemas"]["Timestamp"]; + }; + DigestDefinition: { + id: components["schemas"]["UUID"]; + name: string; + topics: components["schemas"]["DigestTopicInput"][]; + timezone: string; + recurrence: components["schemas"]["Recurrence"]; + enabled: boolean; + version: number; + nextRunAt: components["schemas"]["Timestamp"] | null; + lastSuccessfulAt: components["schemas"]["Timestamp"] | null; + createdAt: components["schemas"]["Timestamp"]; + updatedAt: components["schemas"]["Timestamp"]; + }; + /** @enum {string} */ + ContentOrigin: "unknown" | "article_body" | "article_description" | "paper_abstract" | "video_description" | "repository_readme" | "repository_description" | "empty"; + SummarySourceVersion: string; + /** @description Text and URLs are resolved from owned server content only. */ + ItemSummaryRequest: Record; + ItemSummary: { + sourceVersion: components["schemas"]["SummarySourceVersion"]; + supported: boolean; + canGenerate: boolean; + unavailableReason: ("unsupported_input" | "generation_disabled" | "historical_input") | null; + /** @enum {string} */ + state: "not_requested" | "pending" | "ready" | "failed"; + jobId: components["schemas"]["UUID"] | null; + failure: string | null; + retryable: boolean; + result: components["schemas"]["AISummary"] | null; + }; + AISummary: { + id: components["schemas"]["UUID"]; + generationJobId: components["schemas"]["UUID"]; + sourceVersion: components["schemas"]["SummarySourceVersion"]; + text: string; + model: string; + language: string; + maxInputChars: number; + maxSummaryCharacters: number; + instructionsVersion: string; + algorithmVersion: string; + createdAt: components["schemas"]["Timestamp"]; + }; + DigestIssueSummary: { + id: components["schemas"]["UUID"]; + digestId: components["schemas"]["UUID"]; + generationJobId: components["schemas"]["UUID"]; + title: string; + /** @enum {string} */ + status: "complete" | "degraded"; + scheduledFor: components["schemas"]["Timestamp"] | null; + generatedAt: components["schemas"]["Timestamp"]; + }; + DigestIssue: { + id: components["schemas"]["UUID"]; + digestId: components["schemas"]["UUID"]; + generationJobId: components["schemas"]["UUID"]; + title: string; + /** @enum {string} */ + status: "complete" | "degraded"; + scheduledFor: components["schemas"]["Timestamp"] | null; + generatedAt: components["schemas"]["Timestamp"]; + sections: components["schemas"]["DigestSection"][]; + }; + Job: { + modelTransition?: components["schemas"]["ModelTransition"]; + progress?: components["schemas"]["JobProgress"]; + id: components["schemas"]["UUID"]; + kind: string; + queue: components["schemas"]["JobQueue"]; + /** @enum {string} */ + status: "queued" | "running" | "retry_wait" | "succeeded" | "dead" | "cancelled"; + attempt: number; + maxAttempts: number; + cancellationRequested: boolean; + reason?: string | null; + createdAt: components["schemas"]["Timestamp"]; + startedAt?: components["schemas"]["Timestamp"] | null; + finishedAt?: components["schemas"]["Timestamp"] | null; + /** Format: int64 */ + durationMs?: number | null; + updatedAt: components["schemas"]["Timestamp"]; + }; + /** @description Fixed initial Item cohort progress. Equal counts do not imply successful catch-up or cutover; Job status remains authoritative. */ + JobProgress: { + /** @enum {string} */ + phase: "preparing" | "indexing" | "catching_up" | "activating" | "completed"; + /** Format: int64 */ + processedItems: number; + /** + * Format: int64 + * @description Fixed initial cohort size, unknown until preparation finishes. New Items do not increase this total. + */ + totalItems: number | null; + updatedAt: components["schemas"]["Timestamp"]; + }; + /** @enum {string} */ + JobQueue: "fetch" | "generation" | "retrieval" | "feed-preparation" | "maintenance"; + /** Format: uuid */ + UUID: string; + ComponentStatus: { + name: string; + /** @enum {string} */ + status: "ready" | "degraded" | "unavailable"; + detail?: string; + }; + /** Format: date-time */ + Timestamp: string; + SourceConfig: components["schemas"]["RssSourceConfig"] | components["schemas"]["AtomSourceConfig"] | components["schemas"]["ArxivSourceConfig"] | components["schemas"]["GithubTrendingSourceConfig"] | components["schemas"]["YoutubeSourceConfig"]; + RssSourceConfig: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "rss"; + /** Format: uri */ + url: string; + /** @default false */ + allowPrivateNetwork: boolean; + }; + AtomSourceConfig: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "atom"; + /** Format: uri */ + url: string; + /** @default false */ + allowPrivateNetwork: boolean; + }; + ArxivSourceConfig: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "arxiv"; + /** @example cat:cs.AI */ + query: string; + }; + GithubTrendingSourceConfig: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "github"; + /** @enum {string} */ + period: "daily" | "weekly" | "monthly"; + /** @enum {string} */ + language: "all" | "python" | "rust" | "go" | "javascript" | "typescript"; + }; + YoutubeSourceConfig: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "youtube"; + channelId: string; + }; + /** @enum {string} */ + ItemKind: "article" | "paper" | "video" | "repository"; + ItemDetails: components["schemas"]["ArticleDetails"] | components["schemas"]["PaperDetails"] | components["schemas"]["VideoDetails"] | components["schemas"]["RepositoryDetails"]; + ArticleDetails: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "article"; + description: string; + }; + PaperDetails: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "paper"; + abstract: string; + categories: string[]; + doi: string | null; + }; + VideoDetails: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "video"; + description: string; + channelId: string; + videoId: string; + }; + RepositoryDetails: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "repository"; + description: string | null; + homepage: string | null; + programmingLanguage: string | null; + stars: number | null; + forks: number | null; + openIssues: number | null; + topics: string[]; + defaultBranch: string | null; + starsInPeriod: number | null; + rank: number; + /** @enum {string} */ + period: "daily" | "weekly" | "monthly"; + observedAt: components["schemas"]["Timestamp"]; + /** @enum {string} */ + snapshotStatus: "complete" | "degraded"; + }; + /** @enum {string} */ + TopicMode: "ordinary" | "strict"; + /** + * @description Preserve current membership corrections by default, or clear the complete set explicitly. + * @enum {string} + */ + TopicCorrectionPolicy: "preserve" | "clear"; + SourceList: { + items: components["schemas"]["Source"][]; + }; + SourceInput: { + name: string; + config: components["schemas"]["SourceConfig"]; + enabled: boolean; + /** @default 60 */ + pollIntervalMinutes: number; + }; + JobAccepted: { + jobId: components["schemas"]["UUID"]; + }; + SourcePatch: { + name?: string; + config?: components["schemas"]["SourceConfig"]; + enabled?: boolean; + pollIntervalMinutes?: number; + }; + TopicList: { + items: components["schemas"]["Topic"][]; + }; + TopicInput: { + name: string; + sourceIds: components["schemas"]["UUID"][]; + itemKinds: components["schemas"]["ItemKind"][]; + include: string; + exclude: string; + mode: components["schemas"]["TopicMode"]; + enabled: boolean; + }; + TopicPatch: { + name?: string; + sourceIds?: components["schemas"]["UUID"][]; + itemKinds?: components["schemas"]["ItemKind"][]; + include?: string; + exclude?: string; + mode?: components["schemas"]["TopicMode"]; + enabled?: boolean; + correctionPolicy?: components["schemas"]["TopicCorrectionPolicy"]; + }; + /** @enum {string} */ + ReadingState: "unread" | "read"; + /** @enum {string} */ + ItemVisibility: "visible" | "hidden"; + Feedback: { + /** @enum {string} */ + sentiment: "like" | "dislike"; + comment: string | null; + createdAt: components["schemas"]["Timestamp"]; + updatedAt: components["schemas"]["Timestamp"]; + }; + FeedExplanation: { + summary: string; + signals: { + [key: string]: number; + }; + pipelineVersion: string; + }; + FeedEntry: { + item: components["schemas"]["Item"]; + score: number; + explanation: components["schemas"]["FeedExplanation"]; + }; + SearchPage: { + items: components["schemas"]["FeedEntry"][]; + nextCursor: string | null; + }; + FeedbackInput: { + /** @enum {string} */ + sentiment: "like" | "dislike"; + comment: string | null; + }; + ReadingStateInput: { + state: components["schemas"]["ReadingState"]; + }; + VisibilityInput: { + visibility: components["schemas"]["ItemVisibility"]; + }; + TopicCorrectionInput: { + topicId: components["schemas"]["UUID"]; + /** @enum {string} */ + membership: "include" | "exclude" | "automatic"; + }; + Recurrence: components["schemas"]["DailyRecurrence"] | components["schemas"]["WeeklyRecurrence"] | components["schemas"]["MonthlyRecurrence"]; + DailyRecurrence: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + frequency: "daily"; + hour: number; + minute: number; + }; + WeeklyRecurrence: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + frequency: "weekly"; + weekdays: number[]; + hour: number; + minute: number; + }; + MonthlyRecurrence: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + frequency: "monthly"; + rule: components["schemas"]["MonthlyDayOfMonthRule"] | components["schemas"]["MonthlyNthWeekdayRule"] | components["schemas"]["MonthlyLastDayRule"]; + hour: number; + minute: number; + }; + MonthlyDayOfMonthRule: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "dayOfMonth"; + day: number; + }; + MonthlyNthWeekdayRule: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "nthWeekday"; + weekday: number; + occurrence: number; + }; + MonthlyLastDayRule: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "lastDay"; + }; + DigestTopicInput: { + topicId: components["schemas"]["UUID"]; + /** @default 5 */ + itemLimit: number; + }; + DigestList: { + items: components["schemas"]["DigestDefinition"][]; + }; + DigestInput: { + name: string; + topics: components["schemas"]["DigestTopicInput"][]; + timezone: string; + recurrence: components["schemas"]["Recurrence"]; + enabled: boolean; + }; + DigestPatch: { + name?: string; + topics?: components["schemas"]["DigestTopicInput"][]; + timezone?: string; + recurrence?: components["schemas"]["Recurrence"]; + enabled?: boolean; + }; + DigestSection: { + topicId: components["schemas"]["UUID"]; + topicName: string; + topicRevision: number; + items: components["schemas"]["DigestItem"][]; + failure: string | null; + }; + DigestItem: { + item: components["schemas"]["Item"]; + summary: string; + explanation: components["schemas"]["FeedExplanation"]; + /** @description Separate annotation state for the exact saved Item version. Reading never generates text. */ + aiSummary: components["schemas"]["ItemSummary"] | null; + }; + DigestIssueList: { + items: components["schemas"]["DigestIssueSummary"][]; + nextCursor: string | null; + }; + JobList: { + items: components["schemas"]["Job"][]; + nextCursor: string | null; + }; + ExportBundle: { + /** @constant */ + formatVersion: "topic-stream/v2"; + exportedAt: components["schemas"]["Timestamp"]; + workspaceId: components["schemas"]["UUID"]; + settings: components["schemas"]["WorkspaceSettings"]; + embeddingModel: components["schemas"]["EmbeddingModelDescriptor"]; + preferenceProfile: components["schemas"]["PreferenceProfile"]; + sources: components["schemas"]["Source"][]; + topics: components["schemas"]["Topic"][]; + feedback: components["schemas"]["PortableFeedback"][]; + digests: components["schemas"]["DigestDefinition"][]; + }; + PortableFeedback: { + /** Format: uri */ + canonicalUrl: string; + feedback: components["schemas"]["Feedback"]; + }; + }; + responses: { + /** @description Request failed */ + problem: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Canonical PostgreSQL state committed but its retrieval projection could not be delivered */ + "projection-pending": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProjectionPendingProblem"]; + }; + }; + }; + parameters: { + /** @description Quoted integer version returned in the resource ETag. */ + "if-match-version": string; + "source-id": string; + "topic-id": string; + "item-id": string; + "image-id": string; + "digest-id": string; + "issue-id": string; + "job-id": string; + "model-id": string; + }; + requestBodies: never; + headers: { + /** @description Quoted integer resource version for optimistic updates. */ + "version-etag": string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getSystemStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current daemon and dependency status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SystemStatus"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + streamEvents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Server-sent event stream; clients must recover by refetching HTTP resources */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": string; + }; + }; + default: components["responses"]["problem"]; + }; + }; + listEmbeddingModels: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Registered embedding models */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmbeddingModelList"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + createEmbeddingModel: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EmbeddingModelCreate"]; + }; + }; + responses: { + /** @description Registered model, including a failed initialization state when initialization could not complete */ + 201: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmbeddingModel"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getEmbeddingModel: { + parameters: { + query?: never; + header?: never; + path: { + modelId: components["parameters"]["model-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Registered embedding model */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmbeddingModel"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + updateEmbeddingModel: { + parameters: { + query?: never; + header: { + /** @description Quoted integer version returned in the resource ETag. */ + "If-Match": components["parameters"]["if-match-version"]; + }; + path: { + modelId: components["parameters"]["model-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EmbeddingModelUpdate"]; + }; + }; + responses: { + /** @description Updated embedding model */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmbeddingModel"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + initializeEmbeddingModel: { + parameters: { + query?: never; + header?: never; + path: { + modelId: components["parameters"]["model-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Embedding model after the initialization attempt */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmbeddingModel"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + listEmbeddingModelOptions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Selectable models and current desired or serving models */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmbeddingModelOptionList"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getRetrievalStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current workspace retrieval state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RetrievalStatus"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getWorkspaceSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current workspace settings */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkspaceSettings"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + updateWorkspaceSettings: { + parameters: { + query?: never; + header: { + /** @description Quoted integer version returned in the resource ETag. */ + "If-Match": components["parameters"]["if-match-version"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkspaceSettingsPatch"]; + }; + }; + responses: { + /** @description Updated workspace settings */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkspaceSettings"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getPreferenceProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current preference profile */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreferenceProfile"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + updatePreferenceProfile: { + parameters: { + query?: never; + header: { + /** @description Quoted integer version returned in the resource ETag. */ + "If-Match": components["parameters"]["if-match-version"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PreferenceProfilePatch"]; + }; + }; + responses: { + /** @description Updated preference profile */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreferenceProfile"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + listSources: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Configured sources */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceList"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + createSource: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceInput"]; + }; + }; + responses: { + /** @description Source created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Source"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + syncAllSources: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Synchronization job accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobAccepted"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getSource: { + parameters: { + query?: never; + header?: never; + path: { + sourceId: components["parameters"]["source-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Source */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Source"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + deleteSource: { + parameters: { + query?: never; + header?: never; + path: { + sourceId: components["parameters"]["source-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Source deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["problem"]; + }; + }; + updateSource: { + parameters: { + query?: never; + header?: never; + path: { + sourceId: components["parameters"]["source-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourcePatch"]; + }; + }; + responses: { + /** @description Updated source */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Source"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + syncSource: { + parameters: { + query?: never; + header?: never; + path: { + sourceId: components["parameters"]["source-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Synchronization job accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobAccepted"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + listTopics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Configured topics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TopicList"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + createTopic: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TopicInput"]; + }; + }; + responses: { + /** @description Topic created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Topic"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + deleteTopic: { + parameters: { + query?: never; + header?: never; + path: { + topicId: components["parameters"]["topic-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Topic deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["problem"]; + }; + }; + updateTopic: { + parameters: { + query?: never; + header?: never; + path: { + topicId: components["parameters"]["topic-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TopicPatch"]; + }; + }; + responses: { + /** @description Updated topic */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Topic"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getFeed: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor for older items within the same snapshot */ + cursor?: string; + /** @description Snapshot token returned by the first page; omit it to read the latest saved head without publishing */ + snapshot?: string; + topicId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Saved Feed page */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FeedPage"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + refreshFeed: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FeedRefreshInput"]; + }; + }; + responses: { + /** @description Latest saved Feed page, including when no new batch was published */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FeedPage"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + searchItems: { + parameters: { + query?: { + q?: string; + mode?: "lexical" | "semantic" | "hybrid"; + limit?: number; + cursor?: string; + topicId?: string; + sourceId?: string; + publishedAfter?: string; + publishedBefore?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchPage"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getItem: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Canonical item and personal state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Item"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getItemSummary: { + parameters: { + query?: { + /** @description Read only the exact saved input version for Snapshot capture. */ + sourceVersion?: components["schemas"]["SummarySourceVersion"]; + }; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Input support, generation permission and existing annotation state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ItemSummary"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + requestItemSummary: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ItemSummaryRequest"]; + }; + }; + responses: { + /** @description Accepted active work or an already cached result */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobAccepted"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + requestDigestItemSummary: { + parameters: { + query?: never; + header?: never; + path: { + issueId: components["parameters"]["issue-id"]; + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ItemSummaryRequest"]; + }; + }; + responses: { + /** @description Accepted shared work or an immutable ready annotation */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobAccepted"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + setItemFeedback: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FeedbackInput"]; + }; + }; + responses: { + /** @description Current feedback */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Feedback"]; + }; + }; + 503: components["responses"]["projection-pending"]; + default: components["responses"]["problem"]; + }; + }; + clearItemFeedback: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Feedback removed */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 503: components["responses"]["projection-pending"]; + default: components["responses"]["problem"]; + }; + }; + setItemReadingState: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReadingStateInput"]; + }; + }; + responses: { + /** @description Reading state updated */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["problem"]; + }; + }; + setItemVisibility: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VisibilityInput"]; + }; + }; + responses: { + /** @description Visibility updated */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["problem"]; + }; + }; + setItemTopicCorrection: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TopicCorrectionInput"]; + }; + }; + responses: { + /** @description Topic membership correction updated */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 503: components["responses"]["projection-pending"]; + default: components["responses"]["problem"]; + }; + }; + downloadItemImage: { + parameters: { + query?: never; + header?: never; + path: { + itemId: components["parameters"]["item-id"]; + imageId: components["parameters"]["image-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Validated image bytes */ + 200: { + headers: { + "X-Content-Type-Options": "nosniff"; + [name: string]: unknown; + }; + content: { + "image/*": string; + }; + }; + default: components["responses"]["problem"]; + }; + }; + listDigests: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Digest definitions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigestList"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + createDigest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DigestInput"]; + }; + }; + responses: { + /** @description Digest definition created */ + 201: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigestDefinition"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + deleteDigest: { + parameters: { + query?: never; + header: { + /** @description Quoted integer version returned in the resource ETag. */ + "If-Match": components["parameters"]["if-match-version"]; + }; + path: { + digestId: components["parameters"]["digest-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Digest definition deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["problem"]; + }; + }; + updateDigest: { + parameters: { + query?: never; + header: { + /** @description Quoted integer version returned in the resource ETag. */ + "If-Match": components["parameters"]["if-match-version"]; + }; + path: { + digestId: components["parameters"]["digest-id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DigestPatch"]; + }; + }; + responses: { + /** @description Digest definition updated */ + 200: { + headers: { + ETag: components["headers"]["version-etag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigestDefinition"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + generateDigest: { + parameters: { + query?: never; + header: { + /** @description Permanent request identity scoped to this Workspace and Digest Definition. */ + "Idempotency-Key": string; + }; + path: { + digestId: components["parameters"]["digest-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Generation job accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobAccepted"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + listDigestIssues: { + parameters: { + query?: { + digestId?: string; + /** @description Maximum number of summaries per page. */ + limit?: number; + /** @description Opaque continuation bound to the current Digest filter. */ + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Digest Issue summaries ordered by generatedAt and id descending */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigestIssueList"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getDigestIssue: { + parameters: { + query?: never; + header?: never; + path: { + issueId: components["parameters"]["issue-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Immutable digest issue */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigestIssue"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + listJobs: { + parameters: { + query?: { + status?: "queued" | "running" | "retry_wait" | "succeeded" | "dead" | "cancelled"; + limit?: number; + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Jobs ordered from newest to oldest */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobList"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + getJob: { + parameters: { + query?: never; + header?: never; + path: { + jobId: components["parameters"]["job-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Job details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Job"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + retryJob: { + parameters: { + query?: never; + header?: never; + path: { + jobId: components["parameters"]["job-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Replacement job accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobAccepted"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + cancelJob: { + parameters: { + query?: never; + header?: never; + path: { + jobId: components["parameters"]["job-id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancellation accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Job"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + exportWorkspace: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Portable workspace configuration and personal state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExportBundle"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; + importWorkspace: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExportBundle"]; + }; + }; + responses: { + /** @description Import job accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobAccepted"]; + }; + }; + default: components["responses"]["problem"]; + }; + }; +} diff --git a/plugin/src/api/reader-client.test.ts b/plugin/src/api/reader-client.test.ts new file mode 100644 index 0000000..70ea3d9 --- /dev/null +++ b/plugin/src/api/reader-client.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { TopicStreamClient } from "./client"; +import type { HttpTransport } from "./transport"; +import { fixtureId } from "../test/storybook/fixtures"; + +describe("Reader image API", () => { + it("downloads only an Item image ID and preserves bytes without touching JSON or text getters", async () => { + const bytes = new Uint8Array([137, 80, 78, 71]).buffer; + const transport = vi.fn().mockResolvedValue({ status: 200, headers: { "Content-Type": "image/png; charset=binary" }, arrayBuffer: bytes, + get json(): unknown { throw new Error("Binary success must not parse JSON."); }, get text(): string { throw new Error("Binary success must not decode text."); } }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.downloadItemImage(fixtureId(300), fixtureId(800))).resolves.toEqual({ bytes, contentType: "image/png" }); + expect(transport).toHaveBeenCalledWith({ url: `https://backend.example/api/v1/items/${fixtureId(300)}/images/${fixtureId(800)}`, method: "GET" }); + }); + + it.each([ + { name: "non-image", contentType: "text/html", bytes: new ArrayBuffer(4) }, + { name: "empty", contentType: "image/png", bytes: new ArrayBuffer(0) }, + { name: "oversized", contentType: "image/png", bytes: new ArrayBuffer(10 * 1024 * 1024 + 1) } + ])("rejects a $name response without retry", async ({ contentType, bytes }) => { + const transport = vi.fn().mockResolvedValue({ status: 200, headers: { "content-type": contentType }, arrayBuffer: bytes }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.downloadItemImage(fixtureId(300), fixtureId(800))).rejects.toThrow("invalid image"); + expect(transport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugin/src/api/search-client.test.ts b/plugin/src/api/search-client.test.ts new file mode 100644 index 0000000..2f89322 --- /dev/null +++ b/plugin/src/api/search-client.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { TopicStreamClient } from "./client"; +import type { HttpTransport } from "./transport"; +import { fixtureId } from "../test/storybook/fixtures"; + +describe("archive Search API", () => { + it("reads archive and hybrid filtered pages without publication or cursor interpretation", async () => { + const page = { items: [], nextCursor: "an/opaque+cursor" }; + const transport = vi.fn().mockResolvedValue({ status: 200, json: page }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.searchItems({ limit: 20 })).resolves.toEqual(page); + await client.searchItems({ limit: 20, q: "notas & systems", mode: "hybrid", cursor: "an/opaque+cursor", topicId: fixtureId(200), sourceId: fixtureId(100), publishedAfter: "2026-01-01T00:00:00Z", publishedBefore: "2026-10-28T23:59:59Z" }); + const first = transport.mock.calls[0]![0]; + expect(first).toEqual({ url: "https://backend.example/api/v1/search?limit=20", method: "GET" }); + const second = transport.mock.calls[1]![0]; + const url = new URL(second.url); + expect(second.method).toBe("GET"); + expect(url.pathname).toBe("/api/v1/search"); + expect(Object.fromEntries(url.searchParams)).toEqual({ limit: "20", q: "notas & systems", mode: "hybrid", cursor: "an/opaque+cursor", topicId: fixtureId(200), sourceId: fixtureId(100), publishedAfter: "2026-01-01T00:00:00Z", publishedBefore: "2026-10-28T23:59:59Z" }); + expect(transport).toHaveBeenCalledTimes(2); + }); +}); diff --git a/plugin/src/api/settings-client.test.ts b/plugin/src/api/settings-client.test.ts new file mode 100644 index 0000000..3566126 --- /dev/null +++ b/plugin/src/api/settings-client.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { TopicStreamClient } from "./client"; +import { makeJob, makeSource, makeTopic } from "../test/storybook/fixtures"; + +describe("versioned settings API", () => { + it("cancels through the public Job acknowledgement and retries through its successor reference", async () => { + const job = makeJob({ cancellationRequested: true }); + const transport = vi.fn().mockResolvedValueOnce({ status: 202, json: job }).mockResolvedValue({ status: 202, json: { jobId: "successor" } }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.cancelJob("job/id")).resolves.toEqual(job); + await expect(client.retryJob("job/id")).resolves.toEqual({ jobId: "successor" }); + expect(transport.mock.calls).toEqual([ + [{ url: "https://backend.example/api/v1/jobs/job%2Fid/cancel", method: "POST" }], + [{ url: "https://backend.example/api/v1/jobs/job%2Fid/retry", method: "POST" }] + ]); + }); + it("reads public model options and retrieval state", async () => { + const transport = vi.fn().mockResolvedValueOnce({ status: 200, json: { items: [] } }).mockResolvedValue({ status: 200, json: { desiredModelId: "model", servingModelId: null, transitionJobId: null } }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.listEmbeddingModelOptions()).resolves.toEqual([]); + await client.getRetrievalStatus(); + expect(transport.mock.calls).toEqual([ + [{ url: "https://backend.example/api/v1/embedding-model-options", method: "GET" }], + [{ url: "https://backend.example/api/v1/retrieval-status", method: "GET" }] + ]); + }); + it("reads non-secret Workspace settings and patches with the captured version", async () => { + const transport = vi.fn().mockResolvedValue({ status: 200, json: {} }); + const client = new TopicStreamClient("https://backend.example", transport); + await client.getWorkspaceSettings(); + await client.updateWorkspaceSettings({ summary: { language: "es", maxInputChars: 48000, maxSummaryCharacters: 800 } }, 7); + expect(transport.mock.calls).toEqual([ + [{ url: "https://backend.example/api/v1/workspace-settings", method: "GET" }], + [{ url: "https://backend.example/api/v1/workspace-settings", method: "PATCH", headers: { "If-Match": '"7"' }, contentType: "application/json", body: JSON.stringify({ summary: { language: "es", maxInputChars: 48000, maxSummaryCharacters: 800 } }) }] + ]); + }); + it("uses Digest version preconditions and a stable explicit Build request identity", async () => { + const transport = vi.fn().mockResolvedValue({ status: 200, json: { items: [] } }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.listDigests()).resolves.toEqual([]); + await client.updateDigest("digest/id", { enabled: false }, 4); + transport.mockResolvedValueOnce({ status: 204 }); + await client.deleteDigest("digest/id", 5); + transport.mockResolvedValueOnce({ status: 202, json: { jobId: makeJob().id } }); + await client.generateDigest("digest/id", "fixed-request"); + expect(transport.mock.calls).toEqual([ + [{ url: "https://backend.example/api/v1/digests", method: "GET" }], + [{ url: "https://backend.example/api/v1/digests/digest%2Fid", method: "PATCH", headers: { "If-Match": '"4"' }, contentType: "application/json", body: '{"enabled":false}' }], + [{ url: "https://backend.example/api/v1/digests/digest%2Fid", method: "DELETE", headers: { "If-Match": '"5"' } }], + [{ url: "https://backend.example/api/v1/digests/digest%2Fid/generate", method: "POST", headers: { "Idempotency-Key": "fixed-request" } }] + ]); + }); + it("reads the profile and explicitly patches with the captured If-Match version", async () => { + const profile = { prefer: "", avoid: "", version: 4, revision: 1, updatedAt: "2026-10-27T09:00:00Z" }; + const transport = vi.fn().mockResolvedValue({ status: 200, json: profile }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.getPreferenceProfile()).resolves.toEqual(profile); + await client.updatePreferenceProfile({ prefer: "Sistemas fiables", avoid: "" }, 3); + expect(transport.mock.calls).toEqual([ + [{ url: "https://backend.example/api/v1/preference-profile", method: "GET" }], + [{ url: "https://backend.example/api/v1/preference-profile", method: "PATCH", headers: { "If-Match": '"3"' }, contentType: "application/json", body: JSON.stringify({ prefer: "Sistemas fiables", avoid: "" }) }] + ]); + }); + it("uses separate Source Sync and public Job requests without a preflight read", async () => { + const job = makeJob(); + const transport = vi.fn().mockResolvedValueOnce({ status: 202, json: { jobId: job.id } }).mockResolvedValue({ status: 200, json: job }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.syncSource("source/id")).resolves.toEqual({ jobId: job.id }); + await expect(client.getJob(job.id)).resolves.toEqual(job); + expect(transport.mock.calls).toEqual([ + [{ url: "https://backend.example/api/v1/sources/source%2Fid/sync", method: "POST" }], + [{ url: `https://backend.example/api/v1/jobs/${job.id}`, method: "GET" }] + ]); + }); + it("patches Source fields and accepts a bodyless 204 deletion response", async () => { + const source = makeSource(); + const transport = vi.fn().mockResolvedValueOnce({ status: 200, json: source }).mockResolvedValue({ status: 204, get json() { throw new Error("No response body"); } }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.updateSource(source.id, { enabled: false })).resolves.toEqual(source); + await expect(client.deleteSource(source.id)).resolves.toBeUndefined(); + expect(transport.mock.calls).toEqual([ + [{ url: `https://backend.example/api/v1/sources/${source.id}`, method: "PATCH", contentType: "application/json", body: JSON.stringify({ enabled: false }) }], + [{ url: `https://backend.example/api/v1/sources/${source.id}`, method: "DELETE" }] + ]); + }); + it("updates and deletes a Topic through its public resource without inventing a version header", async () => { + const topic = makeTopic(); + const transport = vi.fn().mockResolvedValueOnce({ status: 200, json: topic }).mockResolvedValue({ status: 204 }); + const client = new TopicStreamClient("https://backend.example", transport); + await expect(client.updateTopic(topic.id, { include: "Sistemas fiables" })).resolves.toEqual(topic); + await expect(client.deleteTopic(topic.id)).resolves.toBeUndefined(); + expect(transport.mock.calls).toEqual([ + [{ url: `https://backend.example/api/v1/topics/${topic.id}`, method: "PATCH", contentType: "application/json", body: JSON.stringify({ include: "Sistemas fiables" }) }], + [{ url: `https://backend.example/api/v1/topics/${topic.id}`, method: "DELETE" }] + ]); + }); +}); diff --git a/plugin/src/api/summary-client.test.ts b/plugin/src/api/summary-client.test.ts new file mode 100644 index 0000000..842aaf6 --- /dev/null +++ b/plugin/src/api/summary-client.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { TopicStreamClient } from "./client"; +import type { HttpTransport } from "./transport"; +import { fixtureId, makeSummary } from "../test/storybook/fixtures"; + +describe("Item summary API", () => { + it("reads an exact text version and sends only explicit canonical or captured-Digest requests", async () => { + const transport = vi.fn().mockResolvedValueOnce({ status: 200, json: makeSummary() }).mockResolvedValue({ status: 202, json: { jobId: fixtureId(400) } }); + const client = new TopicStreamClient("https://backend.example", transport); + await client.getItemSummary(fixtureId(300), "version /+?"); + await client.requestItemSummary(fixtureId(300)); + await client.requestDigestItemSummary(fixtureId(500), fixtureId(300)); + expect(transport.mock.calls.map(([request]) => request)).toEqual([ + { url: `https://backend.example/api/v1/items/${fixtureId(300)}/ai-summary?sourceVersion=version+%2F%2B%3F`, method: "GET" }, + { url: `https://backend.example/api/v1/items/${fixtureId(300)}/ai-summary`, method: "POST", contentType: "application/json", body: "{}" }, + { url: `https://backend.example/api/v1/digest-issues/${fixtureId(500)}/items/${fixtureId(300)}/ai-summary`, method: "POST", contentType: "application/json", body: "{}" } + ]); + }); +}); diff --git a/plugin/src/api/transport.ts b/plugin/src/api/transport.ts new file mode 100644 index 0000000..ce49a37 --- /dev/null +++ b/plugin/src/api/transport.ts @@ -0,0 +1,37 @@ +export interface HttpRequest { + url: string; + method: string; + headers?: Record; + body?: string; + contentType?: string; +} + +export interface HttpResponse { + status: number; + headers?: Record; + text?: string; + readonly json?: unknown; + readonly arrayBuffer?: ArrayBuffer; +} + +export type HttpTransport = (request: HttpRequest) => Promise; + +/** Cancels only this consumer; the host's buffered HTTP request may still finish. */ +export function waitForResponse(transport: HttpTransport, request: HttpRequest, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(new DOMException("Connection closed", "AbortError")); + return new Promise((resolve, reject) => { + const abort = () => { + signal?.removeEventListener("abort", abort); + reject(new DOMException("Connection closed", "AbortError")); + }; + signal?.addEventListener("abort", abort, { once: true }); + Promise.resolve().then(() => { + if (signal?.aborted) throw new DOMException("Connection closed", "AbortError"); + return transport(request); + }).then(resolve, reject).finally(() => signal?.removeEventListener("abort", abort)); + }); +} + +export function isCancellation(error: unknown): boolean { + return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"; +} diff --git a/plugin/src/api/types.ts b/plugin/src/api/types.ts deleted file mode 100644 index b237412..0000000 --- a/plugin/src/api/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -export interface Feedback { - sentiment: "like" | "dislike"; - comment: string | null; - createdAt: string; - updatedAt: string; -} - -export interface Item { - id: string; - sourceId: string; - title: string; - url: string; - canonicalUrl: string; - excerpt: string; - contentMarkdown: string; - authors: string[]; - publishedAt: string | null; - discoveredAt: string; - topicIds: string[]; - readingState: "unread" | "read"; - visibility: "visible" | "hidden"; - feedback: Feedback | null; -} - -export interface FeedEntry { - item: Item; - score: number; - explanation: { - summary: string; - signals: Record; - pipelineVersion: string; - }; -} - -export interface FeedPage { - items: FeedEntry[]; - nextCursor: string | null; - snapshot: string; - generatedAt: string; -} - -export interface Source { - id: string; - name: string; - kind: "rss" | "atom" | "arxiv" | "github" | "youtube"; - url: string; - enabled: boolean; - pollIntervalMinutes: number; - lastSyncedAt: string | null; - createdAt: string; - updatedAt: string; -} - -export interface SourceInput { - name: string; - kind: "rss" | "atom"; - url: string; - enabled: boolean; - pollIntervalMinutes: number; -} - -export interface JobAccepted { - jobId: string; -} - -export type ItemKind = "article" | "paper" | "video" | "repository"; -export type TopicMode = "ordinary" | "strict"; - -export interface Topic { - id: string; - name: string; - sourceIds: string[]; - itemKinds: ItemKind[]; - include: string; - exclude: string; - mode: TopicMode; - enabled: boolean; - revision: number; - createdAt: string; - updatedAt: string; -} - -export interface TopicInput { - name: string; - sourceIds: string[]; - itemKinds: ItemKind[]; - include: string; - exclude: string; - mode: TopicMode; - enabled: boolean; -} diff --git a/plugin/src/api/url.ts b/plugin/src/api/url.ts index df8d9fe..33c2b47 100644 --- a/plugin/src/api/url.ts +++ b/plugin/src/api/url.ts @@ -1,14 +1,17 @@ -export interface FeedPageRequest { - limit: number; +import type { operations } from "./generated/openapi"; + +type FeedQuery = NonNullable; +export type FeedPageRequest = Required> & Pick & { cursor: string | null; snapshot: string | null; -} +}; export const FEED_PAGE_SIZE = 25; export function buildFeedUrl(baseUrl: string, request: FeedPageRequest): string { const url = new URL("/api/v1/feed", normalizedBaseUrl(baseUrl)); url.searchParams.set("limit", String(request.limit)); + if (request.topicId !== undefined) url.searchParams.set("topicId", request.topicId); if (request.cursor !== null) { url.searchParams.set("cursor", request.cursor); } @@ -21,3 +24,15 @@ export function buildFeedUrl(baseUrl: string, request: FeedPageRequest): string function normalizedBaseUrl(baseUrl: string): string { return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; } + +export function normalizeServerUrl(value: string): string { + try { + const url = new URL(value.trim()); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error("Invalid server URL"); + } + return url.toString().replace(/\/+$/, ""); + } catch { + throw new Error("Enter an HTTP or HTTPS server URL without credentials, query or fragment."); + } +} diff --git a/plugin/src/features/connection/ConnectedView.tsx b/plugin/src/features/connection/ConnectedView.tsx new file mode 100644 index 0000000..0f9cb96 --- /dev/null +++ b/plugin/src/features/connection/ConnectedView.tsx @@ -0,0 +1,28 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; + +import { ConnectedShell } from "../shell/ConnectedShell"; +import { ViewErrorBoundary } from "../shell/ViewErrorBoundary"; +import type { ViewConnection } from "./session"; + +import type { SettingsStore } from "../settings/persistence"; +import { WorkspaceState } from "./workspace-state"; +import type { FeedPreferences } from "../feed/preferences"; +import type { MarkdownRendererPort } from "../reader/markdown"; +import type { SnapshotVault } from "../snapshots/snapshot"; +import type { SnapshotHost } from "../snapshots/use-snapshot-workflow"; + +export function ConnectedView({ connection, settings, notify, onStateChange, markdown, vault }: { connection: ViewConnection; settings: SettingsStore; notify: (message: string) => void; onStateChange?: () => void; markdown: MarkdownRendererPort; vault: SnapshotVault }) { + const context = useSyncExternalStore(connection.subscribe, connection.getSnapshot); + const localState = useMemo(() => new WorkspaceState(settings, context.serverUrl, context.client), [settings, context]); + const feedPreferences = useMemo(() => ({ + getSnapshot: () => settings.preferences.feedFilter, + subscribe: (listener) => settings.subscribe(listener), + save: (feedFilter) => settings.savePreferences((current) => ({ ...current, feedFilter })) + }), [settings]); + const snapshots = useMemo(() => ({ vault, preferences: { read: () => settings.preferences.snapshot, save: (snapshot) => settings.savePreferences((current) => ({ ...current, snapshot })) } }), [vault, settings]); + useEffect(() => context.navigation.subscribe(() => onStateChange?.()), [context, onStateChange]); + return + + ; +} diff --git a/plugin/src/features/connection/session.test.ts b/plugin/src/features/connection/session.test.ts new file mode 100644 index 0000000..28669b9 --- /dev/null +++ b/plugin/src/features/connection/session.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ViewConnection } from "./session"; +import type { EventStreamRequest, EventStreamTransport } from "../../shared/event-stream"; +import type { ViewEvent } from "../../shared/events"; +import { fixtureId } from "../../test/storybook/fixtures"; + +function heldStream() { + const requests: EventStreamRequest[] = []; + const open: EventStreamTransport = (request) => new Promise((_resolve, reject) => { + requests.push(request); + request.signal.addEventListener("abort", () => reject(new DOMException("Closed", "AbortError")), { once: true }); + }); + return { requests, open }; +} + +describe("view connection ownership", () => { + it("reopens streaming in the new window without replacing navigation or authoritative queries", async () => { + const stream = heldStream(); + const view = new ViewConnection("http://desktop:7331", vi.fn(), stream.open); + const context = view.getSnapshot(); + context.navigation.open("digests"); + context.navigation.rememberPosition("digests:list", 230); + context.queryClient.setQueryData(["saved-reading"], "retained"); + const receive = vi.fn(); context.events!.subscribe(receive); + view.reconnectEvents(); + expect(stream.requests).toHaveLength(2); + expect(stream.requests[0]!.signal.aborted).toBe(true); + expect(view.getSnapshot()).toBe(context); + expect(context.navigation.position("digests:list")).toBe(230); + expect(context.queryClient.getQueryData(["saved-reading"])).toBe("retained"); + stream.requests[0]!.receive(new TextEncoder().encode(": connected\n\n")); + stream.requests[1]!.receive(new TextEncoder().encode(": connected\n\n")); + await vi.waitFor(() => expect(receive).toHaveBeenCalledExactlyOnceWith({ type: "reconnected" })); + view.dispose(); view.reconnectEvents(); + expect(stream.requests).toHaveLength(2); + expect(stream.requests[1]!.signal.aborted).toBe(true); + }); + it("shares one advisory stream per context and suppresses old endpoint callbacks", async () => { + const stream = heldStream(); + const view = new ViewConnection("http://desktop:7331", vi.fn(), stream.open); + const old = view.getSnapshot(); + expect(old.events).toBeDefined(); + const first = vi.fn<(event: ViewEvent) => void>(); const second = vi.fn<(event: ViewEvent) => void>(); + const stopFirst = old.events!.subscribe(first); const stopSecond = old.events!.subscribe(second); + expect(stream.requests).toHaveLength(1); + const request = stream.requests[0]!; + expect(request.url).toBe("http://desktop:7331/api/v1/events"); + const event = new TextEncoder().encode(`: connected\n\nevent: jobChanged\ndata: {"jobId":"${fixtureId(400)}"}\n\n`); + request.receive(event); + await vi.waitFor(() => expect(first.mock.calls.map(([value]) => value)).toEqual([{ type: "reconnected" }, { type: "jobChanged", jobId: fixtureId(400) }])); + expect(second.mock.calls).toEqual(first.mock.calls); + stopFirst(); expect(request.signal.aborted).toBe(false); + view.replaceServerUrl("https://phone.example"); + expect(request.signal.aborted).toBe(true); + first.mockClear(); second.mockClear(); request.receive(event); + expect(first).not.toHaveBeenCalled(); expect(second).not.toHaveBeenCalled(); + const current = view.getSnapshot(); const latest = vi.fn(); current.events!.subscribe(latest); + expect(stream.requests).toHaveLength(2); + expect(stream.requests[1]!.url).toBe("https://phone.example/api/v1/events"); + stopSecond(); view.dispose(); + expect(stream.requests[1]!.signal.aborted).toBe(true); + stream.requests[1]!.receive(event); + expect(latest).not.toHaveBeenCalled(); + }); + it("replaces the endpoint and cache without delivering old in-flight work", async () => { + let finish!: (value: { status: number; json: unknown }) => void; + const transport = vi.fn(() => new Promise<{ status: number; json: unknown }>((resolve) => { finish = resolve; })); + const view = new ViewConnection("http://desktop:7331", transport); + const old = view.getSnapshot(); + old.queryClient.setQueryData(["selected-item"], "old-private-item"); + old.navigation.open("digests"); + old.navigation.select("digests", "old-private-issue"); + const pending = old.client.listSources(); + const outcome = expect(pending).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(1)); + const changed = vi.fn(); + view.subscribe(changed); + view.replaceServerUrl("https://phone.example/"); + await outcome; + finish({ status: 200, json: { items: [{ id: "old-source" }] } }); + expect(changed).toHaveBeenCalledTimes(1); + expect(view.getSnapshot().serverUrl).toBe("https://phone.example"); + expect(view.getSnapshot().queryClient.getQueryData(["selected-item"])).toBeUndefined(); + expect(view.getSnapshot().navigation.getSnapshot()).toEqual({ primary: "feed", screen: "feed", selections: {} }); + expect(old.queryClient.getQueryCache().getAll()).toEqual([]); + expect(view.getSnapshot().id).not.toBe(old.id); + view.dispose(); + }); + + it("keeps equivalent URLs stable and disposes once with no later callbacks", () => { + const view = new ViewConnection("http://EXAMPLE.ORG:80/", vi.fn()); + const changed = vi.fn(); + const unsubscribe = view.subscribe(changed); + const current = view.getSnapshot(); + view.replaceServerUrl("http://example.org"); + expect(view.getSnapshot()).toBe(current); + expect(changed).not.toHaveBeenCalled(); + unsubscribe(); + view.replaceServerUrl("http://other:7331"); + view.dispose(); + view.dispose(); + expect(changed).not.toHaveBeenCalled(); + view.replaceServerUrl("http://late:7331"); + expect(view.getSnapshot().serverUrl).toBe("http://other:7331"); + }); +}); diff --git a/plugin/src/features/connection/session.ts b/plugin/src/features/connection/session.ts new file mode 100644 index 0000000..f0f406f --- /dev/null +++ b/plugin/src/features/connection/session.ts @@ -0,0 +1,80 @@ +import { QueryClient } from "@tanstack/react-query"; + +import { TopicStreamClient } from "../../api/client"; +import { isCancellation, type HttpTransport } from "../../api/transport"; +import { normalizeServerUrl } from "../../api/url"; +import { ShellNavigation } from "../shell/navigation"; +import type { EventStreamTransport } from "../../shared/event-stream"; +import type { ViewEvents } from "../../shared/events"; +import { AdvisoryConnection } from "../../shared/advisory-connection"; + +export interface ConnectionSnapshot { + id: number; + serverUrl: string; + client: TopicStreamClient; + queryClient: QueryClient; + navigation: ShellNavigation; + events?: ViewEvents | undefined; +} + +export class ViewConnection { + private snapshot: ConnectionSnapshot; + private controller = new AbortController(); + private readonly listeners = new Set<() => void>(); + private closed = false; + private events: AdvisoryConnection | undefined; + + public constructor(serverUrl: string, private readonly transport: HttpTransport, private readonly eventStream?: EventStreamTransport) { + this.snapshot = this.create(normalizeServerUrl(serverUrl), 0); + } + + public getSnapshot = (): ConnectionSnapshot => this.snapshot; + + public reconnectEvents(): void { + if (!this.closed) this.events?.reconnect(); + } + + public replaceServerUrl(value: string): void { + if (this.closed) return; + const url = normalizeServerUrl(value); + if (url === this.snapshot.serverUrl) return; + this.release(); + this.controller = new AbortController(); + this.snapshot = this.create(url, this.snapshot.id + 1); + for (const listener of this.listeners) listener(); + } + + public subscribe = (listener: () => void): (() => void) => { + if (this.closed) return () => undefined; + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + + public dispose(): void { + if (this.closed) return; + this.closed = true; + this.release(); + this.listeners.clear(); + } + + private create(serverUrl: string, id: number): ConnectionSnapshot { + this.events = this.eventStream ? new AdvisoryConnection(serverUrl, this.eventStream) : undefined; + return { + id, serverUrl, + events: this.events, + navigation: new ShellNavigation(), + client: new TopicStreamClient(serverUrl, this.transport, this.controller.signal), + queryClient: new QueryClient({ defaultOptions: { + queries: { retry: (count, error) => !isCancellation(error) && count < 1, staleTime: 30_000 }, + mutations: { retry: false } + } }) + }; + } + + private release(): void { + this.events?.dispose(); + this.controller.abort(); + void this.snapshot.queryClient.cancelQueries(); + this.snapshot.queryClient.clear(); + } +} diff --git a/plugin/src/features/connection/workspace-state.test.ts b/plugin/src/features/connection/workspace-state.test.ts new file mode 100644 index 0000000..7330d28 --- /dev/null +++ b/plugin/src/features/connection/workspace-state.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { WorkspaceState } from "./workspace-state"; +import { SettingsStore } from "../settings/persistence"; +import { FIXTURE_TIME, fixtureId } from "../../test/storybook/fixtures"; + +async function setup() { + return SettingsStore.load({ loadPreferences: () => Promise.resolve(null), savePreferences: () => Promise.resolve(), loadDevice: () => null, saveDevice: () => undefined }); +} +describe("device-local Workspace action state", () => { + it("resolves public identity and isolates a new Workspace at the same endpoint", async () => { + const settings = await setup(); + const status = { status: "ready" as const, version: "test", workspaceId: fixtureId(1), now: FIXTURE_TIME, components: [] }; + const service = { getSystemStatus: vi.fn(async () => status) }; + const first = new WorkspaceState(settings, "http://example.org", service); + await first.update((value) => { value.unresolvedBuilds.digest = "request"; }); + expect((await first.read()).unresolvedBuilds).toEqual({ digest: "request" }); + const another = new WorkspaceState(settings, "http://example.org", { getSystemStatus: async () => ({ ...status, workspaceId: fixtureId(2) }) }); + expect((await another.read()).unresolvedBuilds).toEqual({}); + const endpoint = new WorkspaceState(settings, "http://other.example.org", service); + expect((await endpoint.read()).unresolvedBuilds).toEqual({}); + }); + it("does not persist without verified Workspace identity and can retry a failed status request", async () => { + const settings = await setup(); + const read = vi.spyOn(settings, "readConnection"); + const service = { getSystemStatus: vi.fn<() => Promise>().mockRejectedValue(new Error("Unavailable")) }; + const state = new WorkspaceState(settings, "http://example.org", service); + await expect(state.update((value) => { value.unresolvedBuilds.digest = "request"; })).rejects.toThrow("Unavailable"); + expect(read).not.toHaveBeenCalled(); + await expect(state.read()).rejects.toThrow("Unavailable"); + expect(service.getSystemStatus).toHaveBeenCalledTimes(2); + }); +}); diff --git a/plugin/src/features/connection/workspace-state.ts b/plugin/src/features/connection/workspace-state.ts new file mode 100644 index 0000000..2f1b8cd --- /dev/null +++ b/plugin/src/features/connection/workspace-state.ts @@ -0,0 +1,22 @@ +import type { Schema } from "../../api/contracts"; +import type { SettingsStore } from "../settings/persistence"; +import type { WorkspaceStatePort } from "../settings/device-state"; +import type { ConnectionLocalState } from "../settings/device-state"; + +/** Resolves the public Workspace identity before touching device-local action records. */ +export class WorkspaceState implements WorkspaceStatePort { + private workspace: Promise | null = null; + public constructor(private readonly settings: Pick, private readonly serverUrl: string, private readonly service: { getSystemStatus: () => Promise> }) {} + public subscribe = (listener: () => void): (() => void) => this.settings.subscribe(listener); + public read = async (): Promise => this.settings.readConnection(this.serverUrl, await this.identity()); + public update = async (change: (value: ConnectionLocalState) => void): Promise => { + const workspaceId = await this.identity(); + const state = this.settings.readConnection(this.serverUrl, workspaceId); + change(state); + this.settings.saveConnection(this.serverUrl, workspaceId, state); + }; + private identity(): Promise { + this.workspace ??= this.service.getSystemStatus().then((status) => status.workspaceId).catch((error: unknown) => { this.workspace = null; throw error; }); + return this.workspace; + } +} diff --git a/plugin/src/features/digests/ArchiveBuilds.tsx b/plugin/src/features/digests/ArchiveBuilds.tsx new file mode 100644 index 0000000..ac9d90e --- /dev/null +++ b/plugin/src/features/digests/ArchiveBuilds.tsx @@ -0,0 +1,36 @@ +import { useSyncExternalStore } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { Schema } from "../../api/contracts"; +import { ActionMenu } from "../../shared/components/ActionMenu"; +import { IconButton } from "../../shared/components/IconButton"; +import { DigestBuildControl } from "../settings/DigestBuildControl"; +import type { DigestBuilds, DigestBuildState } from "../settings/digest-builds"; +import type { ShellNavigation } from "../shell/navigation"; +import type { DigestsService } from "./services"; +import { findSavedIssue } from "./saved-result"; +import { ApiErrorMessage } from "../../shared/components/ApiErrorMessage"; + +export function BuildMenu({ definitions, builds }: { definitions: Schema<"DigestDefinition">[]; builds: DigestBuilds }) { + const states = useSyncExternalStore(builds.subscribe, builds.getSnapshot); + return ({ + id: definition.id, label: `Build ${definition.name}${definition.enabled ? "" : " · Manual only"}`, + disabled: buildBusy(states[definition.id]), onSelect: () => { void builds.build(definition.id); } + }))} />; +} +function buildBusy(state: DigestBuildState | undefined): boolean { + return Boolean(state && !["succeeded", "dead", "cancelled"].includes(state.status)); +} +export function ArchiveBuilds({ definitions, builds, navigation, client, onOpen }: { definitions: Schema<"DigestDefinition">[]; builds: DigestBuilds; navigation: ShellNavigation; client: DigestsService; onOpen: (id: string) => void }) { + const states = useSyncExternalStore(builds.subscribe, builds.getSnapshot); + return
{definitions.map((definition) =>
+ {states[definition.id] &&

{definition.name}

} + + {states[definition.id]?.jobId &&
{ void builds.refresh(definition.id); }} /> navigation.openTask(states[definition.id]!.jobId!)} />
} + {states[definition.id]?.status === "succeeded" && } +
)}
; +} +function BuildResult({ client, definition, jobId, onOpen }: { client: DigestsService; definition: Schema<"DigestDefinition">; jobId: string; onOpen: (id: string) => void }) { + const result = useQuery({ queryKey: ["digest-result", jobId], queryFn: ({ signal }) => findSavedIssue(client, jobId, signal, definition.id), retry: false, staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false }); + if (result.data) return ; + return <>{result.error && }{!result.isPending && }; +} diff --git a/plugin/src/features/digests/DigestEdition.tsx b/plugin/src/features/digests/DigestEdition.tsx new file mode 100644 index 0000000..4c07644 --- /dev/null +++ b/plugin/src/features/digests/DigestEdition.tsx @@ -0,0 +1,53 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import type { Schema } from "../../api/contracts"; +import type { FeedService } from "../feed/services"; +import { useFeedItemActions, type ItemActionContext } from "../feed/item-actions"; +import { FeedbackMenu, FeedbackStatus, useItemFeedback } from "../feed/item-feedback"; +import { ItemDetails } from "../feed/FeedCard"; +import { useItemReading } from "../feed/item-reading"; +import { ApiErrorMessage } from "../../shared/components/ApiErrorMessage"; +import type { CapturedCapabilities } from "./DigestReader"; +import { IssueSnapshotAction } from "../snapshots/IssueSnapshotAction"; +import type { SaveIssue } from "../snapshots/use-snapshot-workflow"; + +export interface EditionSelection { section: number; itemId: string; } +export function editionItemKey(value: EditionSelection): string { return `${value.section}:${value.itemId}`; } +interface EditionProps { issue: Schema<"DigestIssue">; client: FeedService; onOpen: (value: EditionSelection) => void; onReadingState: ItemActionContext["onReadingState"]; returnItem: EditionSelection | null; capabilitiesFor: CapturedCapabilities; saveIssue?: SaveIssue | undefined; } + +export function DigestEdition({ issue, client, onOpen, onReadingState, returnItem, capabilitiesFor, saveIssue }: EditionProps) { + const article = useRef(null); + useLayoutEffect(() => { + if (returnItem) Array.from(article.current?.querySelectorAll("[data-edition-item]") ?? []).find((element) => element.dataset.editionItem === editionItemKey(returnItem))?.focus({ preventScroll: true }); + }, [returnItem]); + return
+

{issue.status === "degraded" && " · Partial issue"}

+

{issue.title}

{saveIssue && }
+ + {issue.sections.map((section, sectionIndex) =>
+

{section.topicName}

+ {section.failure &&

This section is incomplete: {section.failure}

} + {section.items.map((saved) => )} + {section.items.length === 0 &&

No Items were captured in this section.

} +
)} +
; +} + +function EditionItem({ saved, selection, client, onOpen, onReadingState, issueId, topicId, capabilitiesFor }: { saved: Schema<"DigestItem">; selection: EditionSelection; client: FeedService; onOpen: EditionProps["onOpen"]; onReadingState: EditionProps["onReadingState"]; issueId: string; topicId: string; capabilitiesFor: CapturedCapabilities }) { + const [details, setDetails] = useState(false); + const [message, setMessage] = useState(null); + const feedback = useItemFeedback(client, saved.item); + const actions = useFeedItemActions({ client, item: saved.item, topicId, onReadingState, onDetails: () => setDetails((value) => !value), onResult: setMessage, capabilities: capabilitiesFor(issueId, saved), summaryHeadingLevel: 4 }); + return

+

{saved.summary || saved.item.excerpt || "No excerpt was captured for this Item."}

+ {message &&

{message}

}{actions.status}{actions.summary} + {details && } +
; +} + +export function DigestContents({ issue, selected, onOpen, client, onReadingState }: { issue: Schema<"DigestIssue">; selected: EditionSelection; onOpen: EditionProps["onOpen"]; client: FeedService; onReadingState: EditionProps["onReadingState"] }) { + return <>

Issue contents

; +} +function ContentsItem({ item, current, section, client, onOpen, onReadingState }: { item: Schema<"Item">; current: boolean; section: number; client: FeedService; onOpen: EditionProps["onOpen"]; onReadingState: EditionProps["onReadingState"] }) { + const reading = useItemReading(client, item, onReadingState); + return <>{reading.error && }; +} diff --git a/plugin/src/features/digests/DigestReader.tsx b/plugin/src/features/digests/DigestReader.tsx new file mode 100644 index 0000000..e613524 --- /dev/null +++ b/plugin/src/features/digests/DigestReader.tsx @@ -0,0 +1,14 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import type { Schema } from "../../api/contracts"; +import { ConnectedItemCard } from "../feed/FeedCard"; +import type { ItemActionContext, ItemActionResult } from "../feed/item-actions"; +import type { FeedItemCapabilities } from "../feed/capabilities"; + +export type CapturedCapabilities = (issueId: string, saved: Schema<"DigestItem">) => FeedItemCapabilities; +export function DigestReader({ issueId, saved, capabilitiesFor, ...context }: ItemActionContext & { issueId: string; saved: Schema<"DigestItem">; capabilitiesFor: CapturedCapabilities }) { + const [details, setDetails] = useState(false); + const [result, setResult] = useState(null); + const article = useRef(null); + useLayoutEffect(() => { article.current?.querySelector("h1 a")?.focus({ preventScroll: true }); }, []); + return
setDetails(true)} onResult={(message) => setResult(message ? { itemId: saved.item.id, message } : null)} actionResult={result} capabilities={capabilitiesFor(issueId, saved)} />
; +} diff --git a/plugin/src/features/digests/DigestsWorkspace.stories.tsx b/plugin/src/features/digests/DigestsWorkspace.stories.tsx new file mode 100644 index 0000000..e89e04d --- /dev/null +++ b/plugin/src/features/digests/DigestsWorkspace.stories.tsx @@ -0,0 +1,257 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { ConnectedShell } from "../shell/ConnectedShell"; +import { ShellNavigation } from "../shell/navigation"; +import { interactiveScenario } from "../../test/storybook/interactive-scenario"; +import { memoryWorkspaceState } from "../../test/storybook/local-state"; +import { memoryFeedPreferences } from "../../test/storybook/feed-preferences"; +import { makeIssue, makeEntry, makeSummary, fixtureId, makeDigestDefinition } from "../../test/storybook/fixtures"; +import type { QueryParameters, Schema } from "../../api/contracts"; +import { deferred } from "../../test/storybook/deferred"; + +const issue = makeIssue(); +const listIssues = fn(async (_input: QueryParameters<"listDigestIssues">): Promise> => ({ items: [issue], nextCursor: null })); +const client = Object.assign(interactiveScenario(), { listDigestIssues: listIssues }); +client.generateDigest = fn(client.generateDigest); +const args = { client, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }), localState: memoryWorkspaceState(), notify: client.notify, feedPreferences: memoryFeedPreferences(), events: client.events }; +const meta = { title: "Features/Digests/Views/DigestsWorkspace", component: ConnectedShell, args, parameters: { shell: true } } satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("button", { name: /Research notes for the week/ })).toBeVisible(); + await expect(listIssues).toHaveBeenCalledWith({ limit: 20 }); + await expect(client.generateDigest).not.toHaveBeenCalled(); + await expect(canvas.queryByText("This section is not available yet.")).not.toBeInTheDocument(); +} }; + +const summaryClient = interactiveScenario(); +summaryClient.issue = makeIssue([makeEntry(0, { summarySourceVersion: "saved-version" })]); +summaryClient.issues = [summaryClient.issue]; +const captured = summaryClient.issue.sections[0]!.items[0]!; +captured.aiSummary = makeSummary({ sourceVersion: "saved-version" }); +summaryClient.setReadingState = fn(async () => undefined); +summaryClient.getItemSummary = fn(summaryClient.getItemSummary); +summaryClient.requestDigestItemSummary = fn(async () => { + captured.aiSummary = makeSummary({ state: "ready", sourceVersion: "saved-version" }); + return { jobId: fixtureId(401) }; +}); +export const CapturedSummaryAndExplanation: Story = { args: { ...args, client: summaryClient, events: summaryClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await canvas.findByRole("button", { name: /Research notes for the week/ })); + await userEvent.click(await canvas.findByRole("button", { name: `More actions for ${captured.item.title}` })); + await userEvent.click(await body.findByRole("menuitem", { name: "Why this item" })); + await expect(await canvas.findByRole("heading", { name: "Why this item" })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: `More actions for ${captured.item.title}` })); + await userEvent.click(await body.findByRole("menuitem", { name: "AI summary" })); + await expect(await canvas.findByText("Synthetic AI summary fixture. This text is not provider evidence.")).toBeVisible(); + await expect(canvas.getByRole("heading", { name: "Research notes for the week", level: 1 })).toBeVisible(); + await expect(summaryClient.requestDigestItemSummary).toHaveBeenCalledTimes(1); + await expect(summaryClient.requestDigestItemSummary).toHaveBeenCalledWith(summaryClient.issue.id, captured.item.id); + await expect(summaryClient.setReadingState).not.toHaveBeenCalled(); + await expect(summaryClient.getItemSummary).not.toHaveBeenCalled(); +} }; + +const buildClient = interactiveScenario(); +buildClient.digests = [makeDigestDefinition()]; +buildClient.generateDigest = fn(buildClient.generateDigest); +export const BuildManualDefinition: Story = { args: { ...args, client: buildClient, events: buildClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Build a Digest Issue" })); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await body.findByRole("menuitem", { name: "Build Manual research · Manual only" })); + await expect(await canvas.findByText("Build queued")).toBeVisible(); + await expect(buildClient.generateDigest).toHaveBeenCalledTimes(1); + await expect(buildClient.generateDigest).toHaveBeenCalledWith(fixtureId(900), expect.stringMatching(/^[0-9a-f-]{36}$/)); + await expect(canvas.getByRole("button", { name: /Research notes for the week/ })).toBeVisible(); + await expect(await canvas.findByRole("button", { name: "View build task for Manual research" })).toBeVisible(); + const job = [...buildClient.jobs.values()].find((value) => value.kind === "generate-digest")!; + buildClient.jobs.set(job.id, { ...job, status: "succeeded" }); + const result = { ...makeIssue([]), id: fixtureId(560), digestId: fixtureId(900), generationJobId: job.id, title: "A freshly built edition", generatedAt: "2026-10-28T10:00:00Z" }; + buildClient.issues.unshift(result); + buildClient.events.emit({ type: "jobChanged", jobId: job.id }); + buildClient.events.emit({ type: "digestReady", issueId: result.id }); + await expect(await canvas.findByText("Build complete")).toBeVisible(); + await expect(canvas.queryByRole("heading", { name: result.title, level: 1 })).not.toBeInTheDocument(); + await userEvent.click(await canvas.findByRole("button", { name: "Open built issue for Manual research" })); + await expect(await canvas.findByRole("heading", { name: result.title, level: 1 })).toBeVisible(); +} }; + +const readinessClient = interactiveScenario(); +const readinessLocal = memoryWorkspaceState(); +export const LiveReadiness: Story = { args: { ...args, client: readinessClient, notify: readinessClient.notify, events: readinessClient.events, localState: readinessLocal, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(async () => { await expect((await readinessLocal.read()).digest.baseline).not.toBeNull(); }); + const issue = { ...makeIssue([]), id: fixtureId(550), title: "A new saved edition", generatedAt: "2026-10-28T09:00:00Z" }; + readinessClient.issues.unshift(issue); + readinessClient.events.emit({ type: "digestReady", issueId: issue.id }); + readinessClient.events.emit({ type: "digestReady", issueId: issue.id }); + await expect(await canvas.findByLabelText("1 unseen issue")).toBeVisible(); + await expect(await canvas.findByRole("button", { name: /A new saved edition.*New/ })).toBeVisible(); + await waitFor(() => expect(readinessClient.notices).toEqual(["Digest ready: A new saved edition"])); + await userEvent.click(canvas.getByRole("button", { name: /A new saved edition/ })); + await expect(await canvas.findByRole("heading", { name: "A new saved edition", level: 1 })).toBeVisible(); + await waitFor(() => expect(canvas.queryByLabelText("1 unseen issue")).not.toBeInTheDocument()); + await expect((await readinessLocal.read()).digest.seenIssueIds).toContain(issue.id); + const offline = { ...issue, id: fixtureId(551), title: "Saved while disconnected", generatedAt: "2026-10-28T10:00:00Z" }; + readinessClient.issues.unshift(offline); + readinessClient.events.emit({ type: "reconnected" }); + await expect(await canvas.findByLabelText("1 unseen issue")).toBeVisible(); + await expect(await canvas.findByRole("button", { name: /Saved while disconnected.*New/ })).toBeVisible(); + readinessClient.issues.unshift({ ...offline, id: fixtureId(552), title: "Another saved issue", generatedAt: "2026-10-28T11:00:00Z" }); + await userEvent.click(canvas.getByRole("button", { name: "Refresh Digest archive" })); + await expect(await canvas.findByLabelText("2 unseen issues")).toBeVisible(); + await expect(readinessClient.notices).toEqual(["Digest ready: A new saved edition"]); +} }; + +export const ManageDefinitions: Story = { args: { ...args, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Digest archive actions" })); + await userEvent.click(await within(canvasElement.ownerDocument.body).findByRole("menuitem", { name: "Manage definitions" })); + await expect(await canvas.findByRole("heading", { name: "Digest definitions" })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Back to reading" })); + await expect(canvas.getByRole("heading", { name: "Digests" })).toBeVisible(); +} }; + +const contentsClient = interactiveScenario(); +contentsClient.setReadingState = fn(async () => undefined); +export const ContentsNavigation: Story = { args: { ...args, client: contentsClient, events: contentsClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: /Research notes for the week/ })); + await userEvent.click(await canvas.findByRole("button", { name: "A closer look at retrieval systems · 1" })); + const contents = within(await canvas.findByRole("navigation", { name: "Issue contents" })); + await userEvent.click(contents.getByRole("button", { name: "Notas de ingeniería sobre búsquedas · 2" })); + await expect(await canvas.findByRole("heading", { name: "Notas de ingeniería sobre búsquedas · 2", level: 1 })).toBeVisible(); + await waitFor(() => expect(contentsClient.setReadingState).toHaveBeenCalledTimes(2)); + await userEvent.click(canvas.getByRole("button", { name: "Back to issue" })); + await expect(canvas.getByRole("button", { name: "Notas de ingeniería sobre búsquedas · 2" })).toHaveFocus(); +} }; + +const failedArchive = Object.assign(interactiveScenario(), { listDigestIssues: fn(async (): Promise> => { throw new Error("Synthetic archive read failure"); }) }); +export const ArchiveErrorAndRecovery: Story = { args: { ...args, client: failedArchive, events: failedArchive.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Could not load Digest archive"); + failedArchive.listDigestIssues = fn(async () => ({ items: [], nextCursor: null })); + await userEvent.click(canvas.getByRole("button", { name: "Refresh Digest archive" })); + await expect(await canvas.findByText("No saved Digest Issues yet.")).toBeVisible(); +} }; +const emptyArchive = interactiveScenario(); emptyArchive.issues = []; +export const Empty: Story = { args: { ...args, client: emptyArchive, events: emptyArchive.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) } }; +const archivePending = deferred>(); +const pendingArchive = Object.assign(interactiveScenario(), { listDigestIssues: fn(() => archivePending.promise) }); +export const Loading: Story = { args: { ...args, client: pendingArchive, events: pendingArchive.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) } }; + +const badgeStorageClient = interactiveScenario(); +const unavailableStorage = Object.assign(memoryWorkspaceState(), { read: async () => { throw new Error("Device storage unavailable"); } }); +export const BadgeStorageFailure: Story = { args: { ...args, client: badgeStorageClient, events: badgeStorageClient.events, localState: unavailableStorage, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("button", { name: /Research notes for the week/ })).toBeVisible(); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Could not update new-issue badges"); +} }; + +const olderClient = Object.assign(interactiveScenario(), { listDigestIssues: fn(async (input: QueryParameters<"listDigestIssues">): Promise> => { + if (input.cursor) throw new Error("Synthetic older page failure"); + return { items: [makeIssue([])], nextCursor: "opaque+older=" }; +}) }); +export const OlderPageFailure: Story = { args: { ...args, client: olderClient, events: olderClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Load older issues" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Could not load older issues"); + await expect(canvas.getByRole("button", { name: /Research notes for the week/ })).toBeVisible(); + await expect(olderClient.listDigestIssues).toHaveBeenCalledWith({ limit: 20, cursor: "opaque+older=" }); +} }; + +const partialClient = interactiveScenario(); +partialClient.issue = { ...makeIssue([makeEntry(0, { title: "Keeping a useful reading practice when the collection grows beyond a single notebook", excerpt: "" }), makeEntry(1)]), title: "Notes for a slower reading week: systems, software craft, and questions to keep open", status: "degraded" }; +partialClient.issue.sections[0]!.items[0]!.summary = ""; +partialClient.issue.sections[1]!.topicName = "Notas de ingeniería para conservar preguntas y volver a pensar"; +partialClient.issue.sections[2]!.failure = "A source was unavailable when this section was built."; +partialClient.issues = [partialClient.issue]; +export const PartialIssue: Story = { args: { ...args, client: partialClient, events: partialClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests", selections: { digests: partialClient.issue.id } }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("heading", { name: partialClient.issue.title, level: 1 })).toBeVisible(); + await expect(canvas.getByText("No excerpt was captured for this Item.")).toBeVisible(); + await expect(canvas.getByText(/This section is incomplete/)).toBeVisible(); +} }; +export const LightEdition: Story = { ...PartialIssue, globals: { theme: "light" } }; +export const NarrowEdition: Story = { ...PartialIssue, parameters: { frame: "narrow" } }; +export const MobileEdition: Story = { ...PartialIssue, parameters: { frame: "mobile" } }; +export const LargeTextEdition: Story = { ...PartialIssue, parameters: { frame: "mobile", fontScale: 2 }, play: async ({ canvasElement }) => { + const leaf = canvasElement.querySelector(".workspace-leaf")!; + await expect(leaf.scrollWidth).toBeLessThanOrEqual(leaf.clientWidth); +} }; + +const filteredClient = interactiveScenario(); +filteredClient.digests = [makeDigestDefinition()]; +filteredClient.issues.unshift({ ...makeIssue([]), id: fixtureId(599), digestId: fixtureId(900), title: "An edition from the selected definition" }); +filteredClient.listDigestIssues = fn(filteredClient.listDigestIssues); +export const DefinitionFilter: Story = { args: { ...args, client: filteredClient, events: filteredClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.selectOptions(await canvas.findByLabelText("Digest definition"), fixtureId(900)); + await expect(await canvas.findByRole("button", { name: /An edition from the selected definition/ })).toBeVisible(); + await expect(canvas.queryByRole("button", { name: /Research notes for the week/ })).not.toBeInTheDocument(); + await expect(filteredClient.listDigestIssues).toHaveBeenCalledWith({ limit: 20, digestId: fixtureId(900) }); +} }; + +const failedSummaryClient = interactiveScenario(); +failedSummaryClient.issue = makeIssue([makeEntry()]); failedSummaryClient.issues = [failedSummaryClient.issue]; +const failedCapture = failedSummaryClient.issue.sections[0]!.items[0]!; +failedCapture.aiSummary = makeSummary({ state: "failed" }); +failedSummaryClient.jobs.set(fixtureId(401), { ...makeJobForSummary(), id: fixtureId(401), status: "dead" }); +failedSummaryClient.retryJob = fn(async () => { failedCapture.aiSummary = makeSummary({ state: "pending", jobId: fixtureId(405) }); return { jobId: fixtureId(405) }; }); +failedSummaryClient.requestDigestItemSummary = fn(failedSummaryClient.requestDigestItemSummary); +failedSummaryClient.setReadingState = fn(async () => undefined); +export const FailedCapturedSummaryAndLateFill: Story = { args: { ...args, client: failedSummaryClient, events: failedSummaryClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests", selections: { digests: failedSummaryClient.issue.id } }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: `More actions for ${failedCapture.item.title}` })); + await userEvent.click(await within(canvasElement.ownerDocument.body).findByRole("menuitem", { name: "AI summary" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Synthetic provider failure"); + await expect(failedSummaryClient.retryJob).not.toHaveBeenCalled(); + await userEvent.click(canvas.getByRole("button", { name: "Retry AI summary" })); + await expect(await canvas.findByText("Summary is being prepared.")).toBeVisible(); + failedCapture.aiSummary = makeSummary({ state: "ready", jobId: fixtureId(405), canGenerate: false, unavailableReason: "generation_disabled" }); + failedSummaryClient.events.emit({ type: "jobChanged", jobId: fixtureId(405) }); + await expect(await canvas.findByText("Synthetic AI summary fixture. This text is not provider evidence.")).toBeVisible(); + await expect(failedSummaryClient.retryJob).toHaveBeenCalledTimes(1); + await expect(failedSummaryClient.requestDigestItemSummary).not.toHaveBeenCalled(); + await expect(failedSummaryClient.setReadingState).not.toHaveBeenCalled(); + await expect(canvas.getByRole("heading", { name: failedSummaryClient.issue.title, level: 1 })).toBeVisible(); +} }; +function makeJobForSummary(): Schema<"Job"> { return { id: fixtureId(401), kind: "summarize-item", queue: "generation", status: "dead", attempt: 1, maxAttempts: 5, cancellationRequested: false, createdAt: "2026-10-27T09:00:00Z", updatedAt: "2026-10-27T09:00:00Z" }; } + +const definitionErrorClient = Object.assign(interactiveScenario(), { listDigests: fn(async (): Promise[]> => { throw new Error("Synthetic definition read failure"); }) }); +export const DefinitionReadFailure: Story = { args: { ...args, client: definitionErrorClient, events: definitionErrorClient.events, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }) }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Could not load Digest definitions"); + await expect(canvas.getByRole("button", { name: /Research notes for the week/ })).toBeVisible(); + definitionErrorClient.listDigests = fn(async () => [makeDigestDefinition()]); + await userEvent.click(canvas.getByRole("button", { name: "Refresh Digest archive" })); + await waitFor(() => expect(canvas.getByRole("button", { name: "Build a Digest Issue" })).toBeEnabled()); +} }; + +const editionClient = Object.assign(interactiveScenario(), { setReadingState: fn(async () => undefined), requestItemSummary: fn(async () => ({ jobId: "never-automatic" })) }); +editionClient.getDigestIssue = fn(editionClient.getDigestIssue); +export const WholeIssueToReaderAndBack: Story = { args: { ...args, client: editionClient, navigation: new ShellNavigation({ primary: "digests", screen: "digests" }), events: editionClient.events }, parameters: { frame: "mobile" }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: /Research notes for the week/ })); + await expect(await canvas.findByRole("heading", { name: "Research notes for the week", level: 1 })).toBeVisible(); + await expect(editionClient.setReadingState).not.toHaveBeenCalled(); + const title = canvas.getByRole("button", { name: "A closer look at retrieval systems · 1" }); + await userEvent.click(title); + await expect(await canvas.findByRole("heading", { name: "A closer look at retrieval systems · 1", level: 1 })).toBeVisible(); + await expect(await canvas.findByText("Synthetic article content for a deterministic UI fixture.")).toBeVisible(); + await expect(editionClient.setReadingState).toHaveBeenCalledTimes(1); + await expect(editionClient.getDigestIssue).toHaveBeenCalledTimes(1); + await expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Back to issue" })); + await expect(canvas.getByRole("heading", { name: "Research notes for the week", level: 1 })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "A closer look at retrieval systems · 1" })).toHaveFocus(); + await userEvent.click(canvas.getByRole("button", { name: "A closer look at retrieval systems · 1" })); + await userEvent.click(canvas.getByRole("button", { name: "More actions for A closer look at retrieval systems · 1" })); + await expect(await within(canvasElement.ownerDocument.body).findByRole("menuitem", { name: "Mark as unread" })).toBeVisible(); + await expect(editionClient.setReadingState).toHaveBeenCalledTimes(1); + await userEvent.keyboard("{Escape}"); + await expect(editionClient.requestItemSummary).not.toHaveBeenCalled(); +} }; diff --git a/plugin/src/features/digests/DigestsWorkspace.tsx b/plugin/src/features/digests/DigestsWorkspace.tsx new file mode 100644 index 0000000..d05e811 --- /dev/null +++ b/plugin/src/features/digests/DigestsWorkspace.tsx @@ -0,0 +1,81 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { Schema } from "../../api/contracts"; +import { ApiErrorMessage } from "../../shared/components/ApiErrorMessage"; +import { ActionMenu } from "../../shared/components/ActionMenu"; +import { IconButton } from "../../shared/components/IconButton"; +import { SplitPane } from "../shell/SplitPane"; +import type { SectionProps } from "../shell/TopicStreamShell"; +import type { DigestsService } from "./services"; +import type { FeedService } from "../feed/services"; +import type { ItemActionContext } from "../feed/item-actions"; +import { DigestContents, DigestEdition, editionItemKey, type EditionSelection } from "./DigestEdition"; +import { DigestReader, type CapturedCapabilities } from "./DigestReader"; +import type { DigestBuilds } from "../settings/digest-builds"; +import type { ShellNavigation } from "../shell/navigation"; +import { ArchiveBuilds, BuildMenu } from "./ArchiveBuilds"; +import type { DigestReadiness } from "./readiness"; +import type { SaveIssue } from "../snapshots/use-snapshot-workflow"; + +export function DigestsWorkspace({ client, capabilitiesFor, onReadingState, builds, readiness, saveIssue, ...props }: SectionProps & { client: DigestsService & FeedService; capabilitiesFor: CapturedCapabilities; onReadingState: ItemActionContext["onReadingState"]; builds: DigestBuilds; readiness: DigestReadiness; saveIssue?: SaveIssue | undefined }) { + const navigation = useSyncExternalStore(props.navigation.subscribe, props.navigation.getSnapshot); + const issueId = navigation.selections.digests; + const [selection, setSelection] = useState<{ issueId: string | undefined; current: EditionSelection | null; last: EditionSelection | null }>({ issueId, current: null, last: null }); + const item = selection.issueId === issueId ? selection.current : null; + const lastItem = selection.issueId === issueId ? selection.last : null; + const [digestId, setDigestId] = useState(""); + const issue = useQuery({ queryKey: ["digest-issue", issueId], queryFn: () => client.getDigestIssue(issueId!), enabled: Boolean(issueId), staleTime: Infinity, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false }); + const loadedIssueId = issue.data?.id; + useEffect(() => { if (props.active && loadedIssueId) void readiness.open(loadedIssueId); }, [props.active, loadedIssueId, readiness]); + const openItem = (value: EditionSelection) => setSelection({ issueId, current: value, last: value }); + const back = () => { if (item) setSelection((value) => ({ ...value, current: null })); else props.navigation.select("digests", null); }; + const archive = { props.navigation.select("digests", null); setDigestId(value); }} onOpen={(id) => { setSelection({ issueId: id, current: null, last: null }); props.navigation.select("digests", id); }} />; + const detail = { void issue.refetch(); }} />} />; + return : archive} detail={detail} />; +} + +function IssueContent({ issue, item, lastItem, client, onOpen, onReadingState, capabilitiesFor, fallback, saveIssue }: { issue: Schema<"DigestIssue"> | undefined; item: EditionSelection | null; lastItem: EditionSelection | null; client: FeedService; onOpen: (selection: EditionSelection) => void; onReadingState: ItemActionContext["onReadingState"]; capabilitiesFor: CapturedCapabilities; fallback: import("react").ReactNode; saveIssue: SaveIssue | undefined }) { + if (!issue) return fallback; + const section = item ? issue.sections[item.section] : undefined; + const saved = section?.items.find((saved) => saved.item.id === item?.itemId); + if (saved && item) return undefined} onResult={() => undefined} onReadingState={onReadingState} capabilitiesFor={capabilitiesFor} />; + return ; +} + +function DigestArchive({ client, digestId, onFilter, onOpen, builds, navigation, readiness }: { client: DigestsService; digestId: string; onFilter: (id: string) => void; onOpen: (id: string) => void; builds: DigestBuilds; navigation: ShellNavigation; readiness: DigestReadiness }) { + const observed = useSyncExternalStore(readiness.subscribe, readiness.getSnapshot); + const definitions = useQuery({ queryKey: ["digests"], queryFn: () => client.listDigests() }); + const savedDefinitions = definitions.data ?? []; + const issues = useInfiniteQuery({ queryKey: ["digest-archive", digestId], retry: false, staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => client.listDigestIssues({ limit: 20, ...(digestId ? { digestId } : {}), ...(pageParam ? { cursor: pageParam } : {}) }), + getNextPageParam: (page) => page.nextCursor }); + const entries = issues.data?.pages.flatMap((page) => page.items) ?? []; + return <> +

Digests

{ void issues.refetch(); void readiness.refresh(); void definitions.refetch(); }} /> navigation.openSettings("digests") }]} />
+ {definitions.error && } + + + { void issues.refetch(); void readiness.refresh(); }} /> +
    {entries.map((issue) => definition.id === issue.digestId)?.name} onOpen={() => onOpen(issue.id)} />)}
+ {issues.hasNextPage && } + ; +} + +function ArchiveFeedback({ pending, error, olderError, empty, badgeError, onRetry }: { pending: boolean; error: Error | null; olderError: boolean; empty: boolean; badgeError: Error | null; onRetry: () => void }) { + return <>{pending &&

Loading Digest archive…

} + {error && <>{!olderError && }} + {!error && badgeError && } + {empty &&

No saved Digest Issues yet.

} + ; +} + +function IssueFeedback({ error, onRetry }: { error: Error | null; onRetry: () => void }) { + return error ? <> :

Loading saved issue…

; +} + +function IssueRow({ issue, definition, onOpen, unseen }: { issue: Schema<"DigestIssueSummary">; definition: string | undefined; onOpen: () => void; unseen: boolean }) { + return
  • ; +} diff --git a/plugin/src/features/digests/readiness.test.ts b/plugin/src/features/digests/readiness.test.ts new file mode 100644 index 0000000..aaaef6a --- /dev/null +++ b/plugin/src/features/digests/readiness.test.ts @@ -0,0 +1,171 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DigestReadiness } from "./readiness"; +import { FixtureScenario } from "../../test/storybook/scenario"; +import { memoryWorkspaceState } from "../../test/storybook/local-state"; +import { fixtureId, makeIssue } from "../../test/storybook/fixtures"; +import { deferred } from "../../test/storybook/deferred"; +import type { Schema } from "../../api/contracts"; +import { SettingsStore } from "../settings/persistence"; +import { WorkspaceState } from "../connection/workspace-state"; + +function setup() { + const client = new FixtureScenario(); + const local = memoryWorkspaceState(); + const notify = vi.fn(); + const create = () => new DigestReadiness(client, local, new QueryClient(), notify); + return { client, local, notify, create }; +} +const newer = (n: number) => ({ ...makeIssue([]), id: fixtureId(550 + n), generatedAt: new Date(Date.parse("2026-10-28T09:00:00Z") + n * 1000).toISOString() }); + +describe("device-local Digest readiness", () => { + afterEach(() => { vi.useRealTimers(); }); + it("still treats a live event during the first-ever head as existing history", async () => { + vi.useFakeTimers(); + const { client, create, notify } = setup(); + const head = deferred>(); + vi.spyOn(client, "listDigestIssues").mockImplementationOnce(() => head.promise); + const getIssue = vi.spyOn(client, "getDigestIssue"); + const model = create(); const close = model.connect(client.events); + const reading = model.refresh(); const issue = newer(1); client.issues = [issue]; + client.events.emit({ type: "digestReady", issueId: issue.id }); + head.resolve({ items: [issue], nextCursor: null }); await reading; + await vi.advanceTimersByTimeAsync(0); + expect(getIssue).toHaveBeenCalledExactlyOnceWith(issue.id); + expect(model.getSnapshot().unseen).toEqual([]); + expect(notify).not.toHaveBeenCalled(); close(); + }); + it("keeps a live readiness Notice when another view is still reconciling its initial head", async () => { + const { client, create, notify } = setup(); + const first = create(); const closeFirst = first.connect(client.events); await first.refresh(); + const head = deferred>(); + const live = deferred>(); + vi.spyOn(client, "listDigestIssues").mockImplementationOnce(() => head.promise); + vi.spyOn(client, "getDigestIssue").mockImplementationOnce(() => live.promise); + const second = create(); const closeSecond = second.connect(client.events); + const reading = second.refresh(); + const issue = newer(3); client.issues.unshift(issue); + client.events.emit({ type: "digestReady", issueId: issue.id }); + await vi.waitFor(() => expect(client.getDigestIssue).toHaveBeenCalledWith(issue.id)); + head.resolve({ items: [issue], nextCursor: null }); await reading; + live.resolve(issue); + await vi.waitFor(() => expect(notify).toHaveBeenCalledExactlyOnceWith(`Digest ready: ${issue.title}`)); + expect(first.getSnapshot().unseen).toEqual([issue.id]); + expect(second.getSnapshot().unseen).toEqual([issue.id]); + closeFirst(); closeSecond(); + }); + it("baselines the first successful unfiltered head once and reconstructs all later pages without catch-up Notices", async () => { + const { client, local, create, notify } = setup(); + client.listDigestIssues = vi.fn(client.listDigestIssues); + client.getDigestIssue = vi.fn(client.getDigestIssue); + client.issues = Array.from({ length: 45 }, (_, n) => ({ ...makeIssue([]), id: fixtureId(1000 + n) })); + const first = create(); const close = first.connect(client.events); await first.refresh(); + const baseline = (await local.read()).digest.baseline; + expect(first.getSnapshot().unseen).toEqual([]); + expect(client.listDigestIssues).toHaveBeenCalledTimes(1); + close(); + const offline = Array.from({ length: 41 }, (_, n) => newer(n)); + client.issues.unshift(...offline); + const reopened = create(); const stop = reopened.connect(client.events); await reopened.refresh(); + expect(new Set(reopened.getSnapshot().unseen)).toEqual(new Set(offline.map((issue) => issue.id))); + expect((await local.read()).digest.baseline).toEqual(baseline); + expect(notify).not.toHaveBeenCalled(); + client.events.emit({ type: "digestReady", issueId: offline[0]!.id }); + await vi.waitFor(() => expect(client.getDigestIssue).toHaveBeenCalledWith(offline[0]!.id)); + await reopened.refresh(); + expect(notify).not.toHaveBeenCalled(); + await reopened.open(offline[0]!.id); await reopened.refresh(); + expect(reopened.getSnapshot().unseen).toHaveLength(40); + stop(); + }); + it("shares one live Notice and synchronizes opened badges across concurrent views on the same device", async () => { + const { client, create, notify } = setup(); + const first = create(); const second = create(); + const closeFirst = first.connect(client.events); const closeSecond = second.connect(client.events); + await Promise.all([first.refresh(), second.refresh()]); + const issue = newer(1); client.issues.unshift(issue); + client.events.emit({ type: "digestReady", issueId: issue.id }); + client.events.emit({ type: "digestReady", issueId: issue.id }); + await vi.waitFor(() => { + expect(first.getSnapshot().unseen).toEqual([issue.id]); + expect(second.getSnapshot().unseen).toEqual([issue.id]); + }); + expect(notify).toHaveBeenCalledTimes(1); + await first.open(issue.id); + await vi.waitFor(() => expect(second.getSnapshot().unseen).toEqual([])); + closeFirst(); closeSecond(); + }); + it("keeps failed initialization retryable and treats its first later success as history", async () => { + const { client, local, create, notify } = setup(); + const original = client.listDigestIssues; + client.listDigestIssues = vi.fn(original).mockRejectedValueOnce(new Error("Head unavailable")); + const model = create(); const close = model.connect(client.events); await model.refresh(); + expect((await local.read()).digest.baseline).toBeNull(); + expect(model.getSnapshot().error?.message).toBe("Head unavailable"); + client.issues.unshift(newer(0)); await model.refresh(); + expect((await local.read()).digest.baseline?.issueId).toBe(newer(0).id); + expect(model.getSnapshot()).toMatchObject({ unseen: [], error: null }); + expect(notify).not.toHaveBeenCalled(); close(); + }); + it("initializes an empty archive and ignores old or opened advisory duplicates after reconnect", async () => { + const { client, create, notify } = setup(); client.issues = []; + const model = create(); const close = model.connect(client.events); await model.refresh(); + const issue = newer(0); client.issues.push(issue); + client.events.emit({ type: "digestReady", issueId: issue.id }); + await vi.waitFor(() => expect(model.getSnapshot().unseen).toEqual([issue.id])); + expect(notify).toHaveBeenCalledTimes(1); + await model.open(issue.id); client.events.emit({ type: "reconnected" }); await model.refresh(); + client.events.emit({ type: "digestReady", issueId: issue.id }); await model.refresh(); + expect(model.getSnapshot().unseen).toEqual([]); expect(notify).toHaveBeenCalledTimes(1); close(); + }); + it("disposes event and storage listeners and discards an in-flight initial head", async () => { + const { client, local, create, notify } = setup(); + const pending = deferred>(); + client.listDigestIssues = vi.fn(() => pending.promise); + client.getDigestIssue = vi.fn(client.getDigestIssue); + const model = create(); const close = model.connect(client.events); const reading = model.refresh(); + await vi.waitFor(() => expect(client.listDigestIssues).toHaveBeenCalledTimes(1)); close(); + pending.resolve({ items: [newer(0)], nextCursor: null }); await reading; + client.events.emit({ type: "digestReady", issueId: newer(0).id }); + expect((await local.read()).digest.baseline).toBeNull(); + expect(client.getDigestIssue).not.toHaveBeenCalled(); expect(notify).not.toHaveBeenCalled(); + }); + it("keeps an unseen badge and reports failed local acknowledgement without an unhandled rejection", async () => { + const { client, local, create } = setup(); + const model = create(); const close = model.connect(client.events); await model.refresh(); + const issue = newer(1); client.issues.unshift(issue); client.events.emit({ type: "digestReady", issueId: issue.id }); + await vi.waitFor(() => expect(model.getSnapshot().unseen).toEqual([issue.id])); + vi.spyOn(local, "update").mockRejectedValueOnce(new Error("Device storage unavailable")); + await expect(model.open(issue.id)).resolves.toBeUndefined(); + expect(model.getSnapshot().unseen).toEqual([issue.id]); + expect(model.getSnapshot().error?.message).toBe("Device storage unavailable"); close(); + }); + it("isolates baseline, seen and Notice records by device, backend and public Workspace identity", async () => { + const loadSettings = () => SettingsStore.load({ loadPreferences: async () => null, savePreferences: async () => undefined, loadDevice: () => null, saveDevice: () => undefined }); + const firstDevice = await loadSettings(); const secondDevice = await loadSettings(); + const configs = [ + { store: firstDevice, endpoint: "https://first.example", workspace: fixtureId(1) }, + { store: firstDevice, endpoint: "https://second.example", workspace: fixtureId(1) }, + { store: firstDevice, endpoint: "https://first.example", workspace: fixtureId(2) }, + { store: secondDevice, endpoint: "https://first.example", workspace: fixtureId(1) } + ]; + const views = configs.map(({ store, endpoint, workspace }) => { + const client = new FixtureScenario(); client.systemStatus.workspaceId = workspace; + const storage = new WorkspaceState(store, endpoint, client); + const notify = vi.fn(); const model = new DigestReadiness(client, storage, new QueryClient(), notify); + return { client, storage, model, notify, close: model.connect(client.events) }; + }); + await Promise.all(views.map(({ model }) => model.refresh())); + const issue = newer(1); for (const view of views) view.client.issues.unshift(issue); + views[0]!.client.events.emit({ type: "digestReady", issueId: issue.id }); + await vi.waitFor(() => expect(views[0]!.notify).toHaveBeenCalledTimes(1)); + await Promise.all(views.slice(1).map(({ model }) => model.refresh())); + await views[0]!.model.open(issue.id); + for (const view of views.slice(1)) { + expect(view.model.getSnapshot().unseen).toEqual([issue.id]); + expect((await view.storage.read()).digest.seenIssueIds).toEqual([]); + expect(view.notify).not.toHaveBeenCalled(); + } + for (const view of views) view.close(); + }); +}); diff --git a/plugin/src/features/digests/readiness.ts b/plugin/src/features/digests/readiness.ts new file mode 100644 index 0000000..6ba473b --- /dev/null +++ b/plugin/src/features/digests/readiness.ts @@ -0,0 +1,117 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { Schema } from "../../api/contracts"; +import type { ViewEvents } from "../../shared/events"; +import type { WorkspaceStatePort, ConnectionLocalState } from "../settings/device-state"; +import type { DigestsService } from "./services"; + +type Baseline = NonNullable; +interface ReadinessSnapshot { unseen: string[]; error: Error | null; } +const boundary = (issue: Schema<"DigestIssueSummary">): Baseline => ({ generatedAt: issue.generatedAt, issueId: issue.id }); +function isNew(issue: Schema<"DigestIssueSummary">, baseline: Baseline): boolean { + return Date.parse(issue.generatedAt) > Date.parse(baseline.generatedAt) || (Date.parse(issue.generatedAt) === Date.parse(baseline.generatedAt) && issue.id > baseline.issueId); +} + +/** Device-scoped observation, independent of edition content and server reading state. */ +export class DigestReadiness { + private snapshot: ReadinessSnapshot = { unseen: [], error: null }; + private baseline: Baseline | null = null; + private listeners = new Set<() => void>(); + private closed = false; + private reading: Promise | null = null; + private live = new Map>(); + public constructor(private readonly client: DigestsService, private readonly storage: WorkspaceStatePort, private readonly queries: QueryClient, private readonly notify: (message: string) => void) {} + public getSnapshot = (): ReadinessSnapshot => this.snapshot; + public subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + public connect(events?: ViewEvents): () => void { + const unsubscribe = events?.subscribe((event) => { + if (event.type === "digestReady") void this.receive(event.issueId).catch((error: unknown) => this.fail(error)); + if (event.type === "reconnected") void this.refresh(); + }); + const unsubscribeStorage = this.storage.subscribe(() => { void this.reconcileSeen().catch((error: unknown) => this.fail(error)); }); + void this.refresh(); + return () => { this.closed = true; unsubscribe?.(); unsubscribeStorage(); this.listeners.clear(); }; + } + private async reconcileSeen(): Promise { + const local = await this.storage.read(); + this.update({ ...this.snapshot, unseen: this.snapshot.unseen.filter((id) => !local.digest.seenIssueIds.includes(id)) }); + } + public refresh = (): Promise => { + if (this.closed) return Promise.resolve(); + this.reading ??= this.read().catch((error: unknown) => this.fail(error)).finally(() => { this.reading = null; }); + return this.reading; + }; + private async read(): Promise { + await this.storage.read(); + const page = await this.client.listDigestIssues({ limit: 20 }); + if (this.closed) return; + let established = false; + await this.storage.update((value) => { + if (value.digest.baseline) return; + value.digest.baseline = page.items[0] ? boundary(page.items[0]) : { generatedAt: "0001-01-01T00:00:00Z", issueId: "" }; + established = true; + }); + if (this.closed) return; + this.baseline = (await this.storage.read()).digest.baseline; + // The initial head defines all older history, including pages not downloaded on this device. + if (established) { this.update({ ...this.snapshot, error: null }); return; } + const discovered = await this.readSinceBaseline(page); + if (this.closed) return; + let unseen: string[] = []; + await this.storage.update((value) => { + unseen = [...new Set([...discovered, ...this.snapshot.unseen])].filter((id) => !value.digest.seenIssueIds.includes(id)); + // Catch-up suppresses later duplicates; an already observed live event keeps its Notice. + value.digest.notifiedIssueIds = [...new Set([...value.digest.notifiedIssueIds, ...discovered.filter((id) => !this.live.has(id))])]; + }); + this.update({ unseen, error: null }); + void this.queries.invalidateQueries({ queryKey: ["digest-archive"] }); + } + private async readSinceBaseline(first: Schema<"DigestIssueList">): Promise { + const ids: string[] = []; + const cursors = new Set(); + let page = first; + while (!this.closed) { + const recent = page.items.filter((issue) => isNew(issue, this.baseline!)); + ids.push(...recent.map((issue) => issue.id)); + if (recent.length < page.items.length || !page.nextCursor) break; + if (cursors.has(page.nextCursor)) throw new Error("The Digest archive returned a repeated page cursor. Refresh to try again."); + cursors.add(page.nextCursor); + page = await this.client.listDigestIssues({ limit: 20, cursor: page.nextCursor }); + } + return ids; + } + public open = async (issueId: string): Promise => { + if (this.closed) return; + try { + await this.storage.update((value) => { if (!value.digest.seenIssueIds.includes(issueId)) value.digest.seenIssueIds.push(issueId); }); + this.update({ ...this.snapshot, unseen: this.snapshot.unseen.filter((id) => id !== issueId) }); + } catch (error) { this.fail(error); } + }; + private receive(id: string): Promise { + const existing = this.live.get(id); + if (existing) return existing; + const action = this.readLive(id).finally(() => { this.live.delete(id); }); + this.live.set(id, action); return action; + } + private async readLive(id: string): Promise { + if (!this.baseline) await this.refresh(); + if (this.closed || !this.baseline) return; + const issue = await this.client.getDigestIssue(id); + if (this.closed || issue.id !== id) return; + let unseen = false; + let notice = false; + await this.storage.update((value) => { + if (!value.digest.baseline || !isNew(issue, value.digest.baseline) || value.digest.seenIssueIds.includes(id)) return; + unseen = true; + if (!value.digest.notifiedIssueIds.includes(id)) { value.digest.notifiedIssueIds.push(id); notice = true; } + }); + if (this.closed) return; + if (unseen) this.update({ ...this.snapshot, unseen: [...new Set([...this.snapshot.unseen, id])] }); + if (notice) this.notify(`Digest ready: ${issue.title}`); + void this.queries.invalidateQueries({ queryKey: ["digest-archive"] }); + } + private fail(error: unknown): void { this.update({ ...this.snapshot, error: error instanceof Error ? error : new Error("Could not refresh new Digest Issues.") }); } + private update(snapshot: ReadinessSnapshot): void { + if (this.closed) return; + this.snapshot = snapshot; for (const listener of this.listeners) listener(); + } +} diff --git a/plugin/src/features/digests/saved-result.ts b/plugin/src/features/digests/saved-result.ts new file mode 100644 index 0000000..7236635 --- /dev/null +++ b/plugin/src/features/digests/saved-result.ts @@ -0,0 +1,18 @@ +import type { Schema } from "../../api/contracts"; +import type { DigestsService } from "./services"; + +/** Public result identity is generationJobId; private Job arguments never participate. */ +export async function findSavedIssue(client: Pick, jobId: string, signal: AbortSignal, digestId?: string): Promise | null> { + let cursor: string | undefined; + const seen = new Set(); + do { + if (signal.aborted) throw new DOMException("View closed", "AbortError"); + const page = await client.listDigestIssues({ limit: 50, ...(digestId ? { digestId } : {}), ...(cursor ? { cursor } : {}) }); + const issue = page.items.find((issue) => issue.generationJobId === jobId); + if (issue) return issue; + if (!page.nextCursor) return null; + if (seen.has(page.nextCursor)) throw new Error("The archive repeated its page cursor. Check the saved result again."); + seen.add(page.nextCursor); cursor = page.nextCursor; + } while (!signal.aborted); + throw new DOMException("View closed", "AbortError"); +} diff --git a/plugin/src/features/digests/services.ts b/plugin/src/features/digests/services.ts new file mode 100644 index 0000000..6382740 --- /dev/null +++ b/plugin/src/features/digests/services.ts @@ -0,0 +1,2 @@ +import type { TopicStreamClient } from "../../api/client"; +export type DigestsService = Pick; diff --git a/plugin/src/features/feed/FeedCapabilities.stories.tsx b/plugin/src/features/feed/FeedCapabilities.stories.tsx new file mode 100644 index 0000000..985dc8d --- /dev/null +++ b/plugin/src/features/feed/FeedCapabilities.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { ConnectedShell } from "../shell/ConnectedShell"; +import { ShellNavigation } from "../shell/navigation"; +import { interactiveScenario } from "../../test/storybook/interactive-scenario"; +import { memoryWorkspaceState } from "../../test/storybook/local-state"; +import { memoryFeedPreferences } from "../../test/storybook/feed-preferences"; +import { deferred } from "../../test/storybook/deferred"; +import type { FeedItemCapabilities, ItemSummaryCapability } from "./capabilities"; +import type { Schema } from "../../api/contracts"; +import { fixtureId, makeSummary } from "../../test/storybook/fixtures"; + +function capabilityArgs(capabilities: FeedItemCapabilities) { + const client = interactiveScenario(); + client.setReadingState = fn(client.setReadingState); + return { client, navigation: new ShellNavigation(), localState: memoryWorkspaceState(), feedPreferences: memoryFeedPreferences(), notify: client.notify, capabilities }; +} +const meta = { title: "Features/Feed/Views/FeedCapabilities", component: ConnectedShell, tags: ["!dev", "!autodocs"], parameters: { shell: true }, args: capabilityArgs({}) } satisfies Meta; +export default meta; +type Story = StoryObj; + +const saved = deferred<{ path: string } | null>(); +const save = fn(async () => saved.promise); +const saveArgs = capabilityArgs({ saveToVault: save }); +export const SaveDoesNotMarkRead: Story = { args: saveArgs, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const document = within(canvasElement.ownerDocument.body); + const title = await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / }); + await expect(save).not.toHaveBeenCalled(); + const more = within(title.closest("li")!).getByRole("button", { name: /^More actions for/ }); + await userEvent.click(more); + await userEvent.click(document.getByRole("menuitem", { name: "Save to vault" })); + await expect(save).toHaveBeenCalledWith(saveArgs.client.collections.entries[0]!.item, more); + await expect(more).toBeDisabled(); + await expect(saveArgs.client.setReadingState).not.toHaveBeenCalled(); + saved.resolve({ path: "Topic Stream/An explicit Snapshot.md" }); + await expect(await canvas.findByText("Saved to vault.")).toBeVisible(); + await expect(title).toHaveAttribute("data-reading-state", "unread"); + await expect(save).toHaveBeenCalledTimes(1); +} }; + +const saveError = fn(async () => { throw new Error("Vault write failed."); }); +const saveErrorArgs = capabilityArgs({ saveToVault: saveError }); +export const SaveError: Story = { args: saveErrorArgs, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const title = await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / }); + const more = within(title.closest("li")!).getByRole("button", { name: /^More actions for/ }); + await userEvent.click(more); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("menuitem", { name: "Save to vault" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Could not save to vault"); + await expect(more).toBeEnabled(); + await expect(saveErrorArgs.client.setReadingState).not.toHaveBeenCalled(); + await expect(saveError).toHaveBeenCalledTimes(1); +} }; + +const renderReader = fn>((item) => item.contentMarkdown); +const readerArgs = capabilityArgs({ renderReader }); +export const DirectReaderCapability: Story = { args: readerArgs, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const title = await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / }); + await expect(renderReader).not.toHaveBeenCalled(); + await userEvent.click(title); + await expect(renderReader).toHaveBeenCalledWith(readerArgs.client.collections.entries[0]!.item); + await expect(canvas.queryByRole("heading", { name: "Why this item" })).not.toBeInTheDocument(); + await waitFor(() => expect(title).toHaveAttribute("data-reading-state", "read")); +} }; + +function summaryPort(initial: Schema<"ItemSummary">, request: ItemSummaryCapability["request"]) { + let state = initial; + const listeners = new Set<() => void>(); + return { + getSnapshot: (item: { id: string }) => item.id === fixtureId(300) ? state : null, + subscribe: (_item: unknown, listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, + request, + render: (item: { id: string }, expanded: boolean) => item.id === fixtureId(300) && expanded ? state.result?.text : null, + update: (value: Schema<"ItemSummary">) => { state = value; for (const listener of listeners) listener(); } + } satisfies ItemSummaryCapability & { update: (value: Schema<"ItemSummary">) => void }; +} +const summaryAccepted = deferred(); +const requestSummary = fn(async () => summaryAccepted.promise); +const liveSummary = summaryPort(makeSummary(), requestSummary); +const summaryArgs = capabilityArgs({ summary: liveSummary }); +export const ExplicitSummaryOnly: Story = { args: summaryArgs, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const document = within(canvasElement.ownerDocument.body); + const title = await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / }); + const row = within(title.closest("li")!); + const excerpt = title.textContent; + await expect(requestSummary).not.toHaveBeenCalled(); + await userEvent.click(row.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(document.getByRole("menuitem", { name: "AI summary" })); + await expect(row.getByRole("group", { name: "AI summary" })).toHaveAttribute("aria-busy", "true"); + await expect(requestSummary).toHaveBeenCalledTimes(1); + liveSummary.update(makeSummary({ state: "pending" })); summaryAccepted.resolve(); + await userEvent.click(row.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(document.getByRole("menuitem", { name: "AI summary" })); + await expect(requestSummary).toHaveBeenCalledTimes(1); + liveSummary.update(makeSummary({ state: "ready" })); + await expect(await row.findByText("Synthetic AI summary fixture. This text is not provider evidence.")).toBeVisible(); + await expect(title.textContent).toBe(excerpt); + await expect(summaryArgs.client.setReadingState).not.toHaveBeenCalled(); + await expect(canvas.getAllByRole("group", { name: "AI summary" })).toHaveLength(1); +} }; + +const optedOut = summaryPort(makeSummary({ canGenerate: false, unavailableReason: "generation_disabled" }), fn(async () => undefined)); +export const SummaryRequiresOptIn: Story = { args: capabilityArgs({ summary: optedOut }), play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const document = within(canvasElement.ownerDocument.body); + const title = await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / }); + await userEvent.click(within(title.closest("li")!).getByRole("button", { name: /^More actions for/ })); + await expect(document.queryByRole("menuitem", { name: "AI summary" })).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + optedOut.update(makeSummary({ state: "ready", canGenerate: false, unavailableReason: "generation_disabled" })); + await userEvent.click(within(title.closest("li")!).getByRole("button", { name: /^More actions for/ })); + await userEvent.click(document.getByRole("menuitem", { name: "AI summary" })); + await expect(await canvas.findByText("Synthetic AI summary fixture. This text is not provider evidence.")).toBeVisible(); + await expect(optedOut.request).not.toHaveBeenCalled(); +} }; + +const unsupported = summaryPort(makeSummary({ supported: false, canGenerate: false, unavailableReason: "unsupported_input" }), fn(async () => undefined)); +export const UnsupportedSummaryHidden: Story = { args: capabilityArgs({ summary: unsupported }), play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const title = await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / }); + await userEvent.click(within(title.closest("li")!).getByRole("button", { name: /^More actions for/ })); + await expect(within(canvasElement.ownerDocument.body).queryByRole("menuitem", { name: "AI summary" })).not.toBeInTheDocument(); + await expect(unsupported.request).not.toHaveBeenCalled(); +} }; + +const failingSummaryRequest = fn(async () => { throw new Error("Generation service unavailable."); }); +const failingSummary = summaryPort(makeSummary(), failingSummaryRequest); +export const SummaryRequestError: Story = { args: capabilityArgs({ summary: failingSummary }), play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const title = await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / }); + const row = within(title.closest("li")!); + await userEvent.click(row.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("menuitem", { name: "AI summary" })); + await expect(await row.findByRole("alert")).toHaveTextContent("Could not request AI summary"); + await userEvent.click(row.getByRole("button", { name: "Retry AI summary" })); + await expect(failingSummaryRequest).toHaveBeenCalledTimes(2); + await expect(title).toHaveAttribute("data-reading-state", "unread"); +} }; + +const paneSummary = summaryPort(makeSummary({ state: "ready" }), fn(async () => undefined)); +export const ReadySummaryInReadingPane: Story = { args: capabilityArgs({ summary: paneSummary }), play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: /^A closer look at retrieval systems · 1 / })); + const pane = within(canvas.getByRole("region", { name: "Reading pane" })); + await userEvent.click(pane.getByRole("button", { name: "AI summary" })); + await expect(pane.getByRole("heading", { name: "AI summary", level: 2 })).toBeVisible(); + await expect(paneSummary.request).not.toHaveBeenCalled(); +} }; diff --git a/plugin/src/features/feed/FeedCard.stories.tsx b/plugin/src/features/feed/FeedCard.stories.tsx new file mode 100644 index 0000000..b14cd4e --- /dev/null +++ b/plugin/src/features/feed/FeedCard.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; + +import { FIXTURE_TIME, makeEntry } from "../../test/storybook/fixtures"; +import { FeedCard } from "./FeedCard"; +import type { FeedService } from "./services"; +import { httpError } from "../../api/errors"; + +const save = fn(async (_itemId, sentiment, comment) => ({ sentiment, comment, createdAt: FIXTURE_TIME, updatedAt: FIXTURE_TIME })); +const meta = { + title: "Features/Feed/Components/FeedCard", component: FeedCard, + args: { entry: makeEntry(), client: { setFeedback: save } }, tags: ["autodocs"] +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Loaded: Story = {}; +export const FeedbackMenu: Story = { play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const document = within(canvasElement.ownerDocument.body); + await expect(canvas.queryByRole("button", { name: "Useful" })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: `More actions for ${args.entry.item.title}` })); + await userEvent.click(document.getByRole("menuitemcheckbox", { name: "Useful" })); + const note = await document.findByRole("dialog", { name: "Feedback note" }); + await expect(args.client.setFeedback).toHaveBeenCalledWith(args.entry.item.id, "like", null); + await expect(await canvas.findByText("Feedback: Useful")).toBeVisible(); + await userEvent.type(within(note).getByRole("textbox", { name: "Optional note for future recommendations" }), "Clear evaluation examples"); + await expect(args.client.setFeedback).toHaveBeenCalledTimes(1); + await userEvent.click(canvas.getByRole("button", { name: `More actions for ${args.entry.item.title}` })); + await expect(document.queryByRole("dialog", { name: "Feedback note" })).not.toBeInTheDocument(); + await expect(document.getByRole("menuitemcheckbox", { name: "Useful" })).toBeChecked(); + await expect(document.getByRole("menuitemcheckbox", { name: "Not for me" })).not.toBeChecked(); + await userEvent.click(document.getByRole("menuitem", { name: "Add note" })); + const reopened = await document.findByRole("dialog", { name: "Feedback note" }); + await expect(within(reopened).getByRole("textbox")).toHaveValue("Clear evaluation examples"); + await expect(args.client.setFeedback).toHaveBeenCalledTimes(1); + await userEvent.click(within(reopened).getByRole("button", { name: "Send note" })); + await expect(args.client.setFeedback).toHaveBeenLastCalledWith(args.entry.item.id, "like", "Clear evaluation examples"); + await expect(await canvas.findByText("Note sent")).toBeVisible(); + await expect(document.queryByRole("dialog")).not.toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: `More actions for ${args.entry.item.title}` })).toHaveFocus(); +} }; +export const LongContent: Story = { args: { entry: makeEntry(0, { title: "A long multilingual Item title: ingeniería y sistemas de búsqueda ".repeat(5) }) } }; +export const FeedbackPending: Story = { + args: { client: { setFeedback: fn(() => new Promise(() => undefined)) } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("menuitemcheckbox", { name: "Useful" })); + await expect(canvas.getByRole("button", { name: /^More actions for/ })).toBeDisabled(); + await expect(within(canvasElement.ownerDocument.body).getByText("Saving feedback…")).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect(within(canvasElement.ownerDocument.body).queryByRole("dialog", { name: "Feedback note" })).not.toBeInTheDocument(); + } +}; +export const FeedbackError: Story = { + args: { client: { setFeedback: fn(() => Promise.reject(new Error("Could not connect to Topic Stream."))) } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("menuitemcheckbox", { name: "Useful" })); + await expect(await within(canvasElement.ownerDocument.body).findByRole("alert")).toHaveTextContent("Could not save feedback"); + } +}; +export const NotePending: Story = { args: { client: { setFeedback: fn(async (_id, sentiment, comment) => { + if (comment) return new Promise(() => undefined); + return { sentiment, comment, createdAt: FIXTURE_TIME, updatedAt: FIXTURE_TIME }; +}) } }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const document = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(document.getByRole("menuitemcheckbox", { name: "Useful" })); + const note = within(await document.findByRole("dialog", { name: "Feedback note" })); + await canvas.findByText("Feedback: Useful"); + await userEvent.type(note.getByRole("textbox"), "My explanation"); + await userEvent.click(note.getByRole("button", { name: "Send note" })); + await expect(note.getByRole("button", { name: "Send note" })).toBeDisabled(); + await expect(note.getByText("Sending note…")).toBeVisible(); +} }; +export const NoteError: Story = { args: { client: { setFeedback: fn(async (_id, sentiment, comment) => { + if (comment) throw new Error("The backend is unavailable."); + return { sentiment, comment, createdAt: FIXTURE_TIME, updatedAt: FIXTURE_TIME }; +}) } }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const document = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(document.getByRole("menuitemcheckbox", { name: "Not for me" })); + const note = within(await document.findByRole("dialog", { name: "Feedback note" })); + await canvas.findByText("Feedback: Not for me"); + await userEvent.type(note.getByRole("textbox"), "Keep this draft"); + await userEvent.click(note.getByRole("button", { name: "Send note" })); + await expect(await note.findByRole("alert")).toHaveTextContent("Could not send note"); + await expect(canvas.getByText("Feedback: Not for me")).toBeVisible(); + await expect(note.getByRole("textbox")).toHaveValue("Keep this draft"); + await expect(note.getByRole("button", { name: "Send note" })).toBeEnabled(); +} }; + +export const FeedbackSavedProjectionPending: Story = { args: { client: { setFeedback: fn(async () => { + throw httpError(503, JSON.stringify({ type: "https://topic-stream.dev/problems/projection-pending", title: "Retrieval update pending", status: 503, code: "projection_pending", canonicalCommitted: true, projectionRevision: "opaque-revision-17" })); +}) } }, play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const document = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(document.getByRole("menuitemcheckbox", { name: "Useful" })); + await expect(await canvas.findByText("Feedback: Useful")).toBeVisible(); + await expect(await document.findByText("Saved. Retrieval update pending.")).toBeVisible(); + await expect(document.queryByRole("alert")).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await expect(document.getByRole("menuitemcheckbox", { name: "Useful" })).toBeChecked(); + await expect(args.client.setFeedback).toHaveBeenCalledTimes(1); +} }; diff --git a/plugin/src/features/feed/FeedCard.tsx b/plugin/src/features/feed/FeedCard.tsx new file mode 100644 index 0000000..01dd74f --- /dev/null +++ b/plugin/src/features/feed/FeedCard.tsx @@ -0,0 +1,45 @@ +import type { FeedService } from "./services"; +import type { FeedEntry } from "../../api/contracts"; +import { FeedbackMenu, FeedbackStatus, useItemFeedback } from "./item-feedback"; +import { externalUrl } from "../../obsidian/services/external-links"; +import type { ReactNode } from "react"; +import type { ActionMenuItem } from "../../shared/components/ActionMenu"; +import { useFeedItemActions, type ItemActionContext, type ItemActionResult } from "./item-actions"; +export type ItemPresentation = Omit & { score?: number }; + +export function ConnectedItemCard({ entry, details, actionResult, ...context }: ItemActionContext & { entry: ItemPresentation; details: boolean; actionResult?: ItemActionResult | null }) { + const actions = useFeedItemActions({ ...context, item: entry.item }); + const content = details ? undefined : context.capabilities?.renderReader?.(entry.item); + const notice = actionResult?.itemId === entry.item.id ?

    {actionResult.message}

    : null; + return action.id !== "summary")} actionsPending={actions.pending} actionStatus={<>{notice}{actions.status}} content={content} annotation={<>{!details && actions.summaryTrigger}{actions.summary}} />; +} + +export function FeedCard({ client, entry, headingLevel = 2, details = false, onOpenOriginal, extraMenuItems = [], actionsPending = false, actionStatus, content, annotation }: { client: Pick; entry: ItemPresentation; headingLevel?: 1 | 2; details?: boolean; onOpenOriginal?: (anchor: HTMLAnchorElement) => void; extraMenuItems?: ActionMenuItem[]; actionsPending?: boolean; actionStatus?: ReactNode; content?: ReactNode; annotation?: ReactNode }) { + const Heading = `h${headingLevel}` as const; + const feedback = useItemFeedback(client, entry.item); + const date = entry.item.publishedAt === null + ? "Recently discovered" + : new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(entry.item.publishedAt)); + return ; +} + +export function ItemDetails({ entry: { item, score, explanation }, headingLevel = 2 }: { entry: ItemPresentation; headingLevel?: 2 | 4 }) { + const Heading = `h${headingLevel}` as const; + return <>
    Why this item

    {explanation.summary}

    +
    {score !== undefined && <>
    Recommendation score
    {score}
    } + {Object.entries(explanation.signals).map(([name, value]) =>
    {name}
    {value}
    )} +
    Pipeline version
    {explanation.pipelineVersion}
    +
    Item details
    +
    Kind
    {item.kind}
    Authors
    {item.authors.join(", ") || "Not provided"}
    +
    Discovered
    +
    Canonical URL
    {item.canonicalUrl}
    ; +} diff --git a/plugin/src/features/feed/FeedMessage.tsx b/plugin/src/features/feed/FeedMessage.tsx new file mode 100644 index 0000000..08736b5 --- /dev/null +++ b/plugin/src/features/feed/FeedMessage.tsx @@ -0,0 +1,8 @@ +export function FeedMessage({ title, detail }: { title: string; detail: string }) { + return ( +
    +

    {title}

    +

    {detail}

    +
    + ); +} diff --git a/plugin/src/features/feed/FeedRow.stories.tsx b/plugin/src/features/feed/FeedRow.stories.tsx new file mode 100644 index 0000000..bb3cc89 --- /dev/null +++ b/plugin/src/features/feed/FeedRow.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { FeedRow } from "./FeedRow"; +import { makeEntry } from "../../test/storybook/fixtures"; +import { interactiveScenario } from "../../test/storybook/interactive-scenario"; + +const meta = { + title: "Features/Feed/Components/FeedRow", component: FeedRow, + args: { client: interactiveScenario(), entry: makeEntry(), selected: false, topicId: null, onOpen: fn(), onDetails: fn(), onReadingState: fn(), onResult: fn() }, + decorators: [(Story) =>
    ] +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Loaded: Story = { play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByText("Article")).toBeVisible(); + await expect(within(canvasElement).getByRole("button", { name: /^A closer look/ })).toHaveAttribute("data-reading-state", "unread"); +} }; +export const Read: Story = { args: { entry: makeEntry(0, { readingState: "read" }) } }; +export const Selected: Story = { args: { selected: true } }; +export const LongContent: Story = { parameters: { frame: "mobile" }, args: { entry: makeEntry(0, { title: "Ingeniería y sistemas de búsqueda ".repeat(8), excerpt: "A long excerpt with an unbroken identifier " + "LongIdentifier".repeat(25) }) }, play: async ({ canvasElement }) => { + const row = canvasElement.querySelector(".topic-stream-feed-row")!; + await expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth); +} }; +const readPending = interactiveScenario(); +readPending.setReadingState = () => new Promise(() => undefined); +export const ReadingPending: Story = { args: { client: readPending }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("menuitem", { name: "Mark as read" })); + await expect(canvas.getByRole("button", { name: /^More actions for/ })).toBeDisabled(); +} }; +const hideError = interactiveScenario(); +hideError.setVisibility = async () => { throw new Error("Server unavailable."); }; +export const HideError: Story = { args: { client: hideError }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /^More actions for/ })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("menuitem", { name: "Hide from all Feeds" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Could not hide Item"); + await expect(canvas.getByRole("button", { name: /^More actions for/ })).toBeEnabled(); +} }; diff --git a/plugin/src/features/feed/FeedRow.tsx b/plugin/src/features/feed/FeedRow.tsx new file mode 100644 index 0000000..30ac168 --- /dev/null +++ b/plugin/src/features/feed/FeedRow.tsx @@ -0,0 +1,23 @@ +import type { FeedEntry } from "../../api/contracts"; +import { FeedbackMenu, FeedbackStatus, useItemFeedback } from "./item-feedback"; +import { useFeedItemActions, type ItemActionContext } from "./item-actions"; + +export function FeedRow({ entry, selected, onOpen, ...context }: ItemActionContext & { + entry: FeedEntry; selected: boolean; onOpen: () => void; +}) { + const feedback = useItemFeedback(context.client, entry.item); + const actions = useFeedItemActions({ ...context, item: entry.item }); + const date = entry.item.publishedAt ?? entry.item.discoveredAt; + const openReading = () => { + if (entry.item.readingState === "unread") actions.reading.mark("read"); + onOpen(); + }; + return
  • + {actions.summary}{actions.status} +
  • ; +} + +const kindLabels = { article: "Article", paper: "Paper", video: "Video", repository: "Repository" } as const; diff --git a/plugin/src/features/feed/FeedView.tsx b/plugin/src/features/feed/FeedView.tsx new file mode 100644 index 0000000..712d923 --- /dev/null +++ b/plugin/src/features/feed/FeedView.tsx @@ -0,0 +1,197 @@ +import { useInfiniteQuery, useIsMutating, useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import type { FeedPage } from "../../api/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; + +import type { FeedPageRequest } from "../../api/client"; +import { FEED_PAGE_SIZE } from "../../api/url"; +import { ApiErrorMessage } from "../../shared/components/ApiErrorMessage"; + +import { SplitPane } from "../shell/SplitPane"; +import type { SectionProps } from "../shell/TopicStreamShell"; +import { IconButton } from "../../shared/components/IconButton"; +import { ConnectedItemCard } from "./FeedCard"; +import { FeedMessage } from "./FeedMessage"; +import { TopicSwitcher } from "./TopicSwitcher"; +import { feedPageParams, refreshFeedTraversal } from "./refresh-feed"; +import type { FeedService } from "./services"; +import type { FeedPreferences } from "./preferences"; +import { FeedRow } from "./FeedRow"; +import { ItemActionListNotice, type ItemActionContext, type ItemActionResult } from "./item-actions"; +import { clearFeedUnread, retainFeedUnread, useUnreadMembership } from "./unread-membership"; +import { reconcilePageFeedback } from "./item-feedback"; +import { useFeedUpdates } from "./feed-updates"; +import type { ViewEvents } from "../../shared/events"; +import type { FeedItemCapabilities } from "./capabilities"; + +interface FeedViewProps extends SectionProps { client: FeedService; feedPreferences: FeedPreferences; onReadingState: ItemActionContext["onReadingState"]; events?: ViewEvents | undefined; capabilities?: FeedItemCapabilities | undefined; } + +export function FeedView({ client, feedPreferences, events, capabilities, onReadingState, ...props }: FeedViewProps) { + const navigation = useSyncExternalStore(props.navigation.subscribe, props.navigation.getSnapshot); + const sentinel = useRef(null); + const [topicId, setTopicId] = useState(null); + const [detailsId, setDetailsId] = useState(null); + const [actionResult, setActionResult] = useState(null); + const filter = useSyncExternalStore(feedPreferences.subscribe, feedPreferences.getSnapshot); + const queries = useQueryClient(); + const retained = useUnreadMembership(); + const retainUnread = useCallback(() => { + if (filter !== "unread") return; + retainFeedUnread(queries); + }, [filter, queries]); + const updates = useFeedUpdates(client, events, retainUnread); + const selections = useRef(new Map()); + const [heads, setHeads] = useState(new Map()); + const { query, failure, refresh, refreshing } = useFeedPages(client, topicId, (selected, hasNew, snapshot) => { + updates.clear(selected); + clearFeedUnread(queries, selected); + if (hasNew) { + props.navigation.rememberPosition(`${feedPositionKey(selected)}:list`, 0); + setHeads((current) => new Map(current).set(selected, { snapshot, revision: (current.get(selected)?.revision ?? 0) + 1 })); + } + }); + + const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; + const entries = useMemo(() => query.data?.pages.flatMap((page) => page.items) ?? [], [query.data]); + const visible = visibleEntries(entries, filter, retained.get(topicId)); + const autoLoad = visible.length > 0 && !query.isFetchNextPageError; + useEffect(() => { + const element = sentinel.current; + const Observer = element?.ownerDocument.defaultView?.IntersectionObserver; + if (element === null || !hasNextPage || Observer === undefined || refreshing || !autoLoad) { + return; + } + const observer = new Observer(([entry]) => { + if (entry?.isIntersecting === true && !isFetchingNextPage) { + void fetchNextPage(); + } + }, { rootMargin: "240px" }); + observer.observe(element); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage, refreshing, autoLoad]); + const selected = entries.find((entry) => entry.item.id === navigation.selections[props.section]); + const listRevision = feedRevision(query.data, heads.get(topicId)); + const changeTopic = (id: string | null) => { + if (id === topicId) return; + selections.current.set(topicId, navigation.selections[props.section] ?? null); + props.navigation.select(props.section, selections.current.get(id) ?? null); + setTopicId(id); + }; + return +

    Feed

    + + +
    + + + {updates.error && } + { void query.refetch(); }} onSettings={() => props.navigation.open("settings")} /> + +
      + {visible.map((entry) => setActionResult(message ? { itemId: entry.item.id, message } : null)} capabilities={capabilities} selected={navigation.selections[props.section] === entry.item.id} + onOpen={() => { setDetailsId(null); props.navigation.select(props.section, entry.item.id); }} + onDetails={() => { setDetailsId(entry.item.id); props.navigation.select(props.section, entry.item.id); }} onReadingState={onReadingState} />)} +
    +