From 2c100d72b6af0b93e6e78fb353d998767c75a052 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 18:18:28 +0100 Subject: [PATCH] feat: persist settings and enrich conversation context --- agents.md | 27 +- apps/dashboard/src/components/origin-post.tsx | 72 ++++++ .../src/components/reddit-user-text.tsx | 16 ++ .../src/components/reddit-username-link.tsx | 42 ++++ .../conversations/conversation-api.ts | 5 +- .../conversations/conversation-detail.tsx | 44 ++-- .../conversations/conversation-route.tsx | 7 +- .../conversations/conversations-page.tsx | 19 +- .../features/conversations/conversations.css | 16 +- .../conversations/processed-user-lookup.tsx | 5 +- .../conversations/start-conversation-form.tsx | 3 +- .../src/features/media/media-card.tsx | 7 +- apps/dashboard/src/features/media/media.css | 6 +- .../features/operations/operations-page.tsx | 11 +- .../features/operations/realtime-client.ts | 4 +- .../src/features/overview/overview-page.tsx | 8 +- .../src/features/overview/recent-messages.tsx | 3 +- .../src/features/settings/access-panel.tsx | 3 +- .../src/features/settings/settings-editor.tsx | 1 + .../src/features/settings/settings-model.ts | 16 ++ .../src/features/settings/settings-queries.ts | 1 + .../features/settings/settings-sections.tsx | 17 ++ apps/dashboard/src/styles.css | 18 ++ docs/api.md | 4 +- docs/architecture.md | 26 +- docs/configuration.md | 77 +++--- docs/data-flow.md | 17 +- docs/deployment.md | 64 ++--- docs/development.md | 29 +-- docs/services.md | 89 +------ packages/api-contracts/src/activity.ts | 2 + packages/api-contracts/src/chats.ts | 2 + packages/api-contracts/src/config.ts | 3 + packages/api-contracts/src/messages.ts | 2 + .../api-contracts/tests/contracts.test.ts | 5 +- src/bootstrap.ts | 71 +++--- src/browser/client.ts | 8 +- src/config/index.ts | 21 +- src/config/persistence.ts | 137 +++++----- src/database/client.ts | 52 +++- src/database/repositories.ts | 41 ++- src/index.ts | 10 +- src/main-loop.ts | 10 +- src/reddit/api.ts | 21 ++ src/services/chat/index.ts | 2 + src/services/chat/matrix/client.ts | 48 +++- .../chat/matrix/http-media-downloader.ts | 4 + src/services/chat/matrix/username-resolver.ts | 29 +++ src/services/chat/service.ts | 237 ++++++++++++------ src/services/chat/types.ts | 1 + src/services/response-checker.ts | 12 +- src/web/activity-log.ts | 12 +- src/web/context.ts | 2 + src/web/reddit-identities.ts | 39 +++ src/web/routes/config.ts | 69 ++++- src/web/routes/conversations.ts | 63 +++-- src/web/routes/media.ts | 11 +- src/web/routes/stats.ts | 9 +- src/web/server.ts | 15 +- src/web/types.ts | 2 + tests/activity-log-origin.test.ts | 30 +++ tests/browser-client.test.ts | 23 +- tests/chat-service-auth-gate.test.ts | 73 +++++- .../chat-service-progressive-backfill.test.ts | 197 +++++++++++++++ tests/chat-service-username-persist.test.ts | 67 +++++ tests/config-persistence.test.ts | 191 ++++++-------- tests/dashboard/app.test.tsx | 47 ++++ tests/dashboard/conversations.test.tsx | 56 +++++ tests/dashboard/media.test.tsx | 3 + .../dashboard/performance-operations.test.tsx | 4 +- tests/dashboard/realtime-client.test.ts | 10 + tests/dashboard/reddit-username-link.test.tsx | 48 ++++ tests/dashboard/route-filters.test.tsx | 4 +- tests/dashboard/settings.test.tsx | 24 ++ tests/database.test.ts | 10 +- tests/matrix-client.test.ts | 63 ++++- tests/matrix-http-media-downloader.test.ts | 25 ++ tests/matrix-username-resolver.test.ts | 50 ++++ tests/reddit-api.test.ts | 40 +++ tests/repositories.test.ts | 10 +- tests/response-checker.test.ts | 33 +++ tests/web/conversations-route.test.ts | 180 +++++++++++++ tests/web/media-route.test.ts | 66 +++++ tests/web/reddit-identities.test.ts | 41 +++ 84 files changed, 2248 insertions(+), 644 deletions(-) create mode 100644 apps/dashboard/src/components/origin-post.tsx create mode 100644 apps/dashboard/src/components/reddit-user-text.tsx create mode 100644 apps/dashboard/src/components/reddit-username-link.tsx create mode 100644 src/services/chat/matrix/username-resolver.ts create mode 100644 src/web/reddit-identities.ts create mode 100644 tests/activity-log-origin.test.ts create mode 100644 tests/chat-service-progressive-backfill.test.ts create mode 100644 tests/dashboard/reddit-username-link.test.tsx create mode 100644 tests/matrix-http-media-downloader.test.ts create mode 100644 tests/matrix-username-resolver.test.ts create mode 100644 tests/web/conversations-route.test.ts create mode 100644 tests/web/media-route.test.ts create mode 100644 tests/web/reddit-identities.test.ts diff --git a/agents.md b/agents.md index ac5ddf3..3be2065 100644 --- a/agents.md +++ b/agents.md @@ -19,20 +19,11 @@ Refactor code to improve maintainability, testability, and clarity. Break down l - **Zod Validation**: Use `zod` (already in deps) to validate external data (config files, API responses). - **Async/Await**: properly handle promises. Use `p-queue` or `p-retry` for rate-limited operations. -### 4. Test-Driven Development (TDD) — REQUIRED -All new functionality and bug fixes MUST follow the red-green-refactor cycle. This is non-negotiable. - -- **Red**: Write a failing test FIRST that describes the desired behavior. Run it and confirm it fails for the expected reason. -- **Green**: Write the minimum production code needed to make the test pass. No extra features, no speculative abstractions. -- **Refactor**: With tests green, clean up implementation and tests. Re-run the suite after each change. - -**Rules**: -- Never commit production code without an accompanying test that fails without it. +### 4. Testing +- TDD is not required. Agents may implement changes before writing or updating tests. +- Add or update tests when they provide useful coverage for new functionality or bug fixes. - Tests live in `tests/` (mirroring `src/` structure) and run via `vitest` (`npm test`). -- Use dependency injection so collaborators (DB, HTTP, browser, clock) can be faked — do not mock what you don't own; wrap third-party APIs in a thin interface and fake the interface. - Prefer behavior-focused tests (inputs → outputs / observable side effects) over tests that pin internal structure. -- Bug fixes start with a regression test that reproduces the bug. -- If a change is genuinely untestable (e.g. a one-line config tweak), state that explicitly in the PR/commit rather than skipping silently. ### 5. Project Specifics - **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite, Vitest. @@ -45,10 +36,10 @@ All new functionality and bug fixes MUST follow the red-green-refactor cycle. Th ## Workflow for Agents 1. **Analyze**: Before writing code, read the file and understand the context. -2. **Plan**: Identify what needs to change. Check if the change violates SRP. Identify the test(s) that will drive the change. -3. **Write failing test (Red)**: Add a vitest spec that captures the new behavior or reproduces the bug. Run `npm test` and confirm it fails for the right reason. -4. **Implement (Green)**: Write the smallest change that makes the test pass. Create new small files when SRP calls for it. -5. **Refactor**: Tidy the implementation and the test with the suite green. Re-run tests after each refactor step. -6. **Verify**: Ensure the full suite passes (`npm test`) and types check (`npm run build` or `tsc --noEmit`). +2. **Plan**: Identify what needs to change and check if the change violates SRP. +3. **Implement**: Make the scoped change. Create new small files when SRP calls for it. +4. **Test**: Add or update useful tests as appropriate for the change. +5. **Refactor**: Tidy the implementation and tests while preserving behavior. +6. **Verify**: Run the relevant tests and type checks (`npm run build` or `tsc --noEmit`) in proportion to the change. -You must use the dokploy credentials in .env for access to dokploy \ No newline at end of file +You must use the dokploy credentials in .env for access to dokploy diff --git a/apps/dashboard/src/components/origin-post.tsx b/apps/dashboard/src/components/origin-post.tsx new file mode 100644 index 0000000..a21fb34 --- /dev/null +++ b/apps/dashboard/src/components/origin-post.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +export interface OriginPost { + title: string; + url: string; + imageUrl?: string | null; + subreddit?: string; +} + +export function OriginPostCard({ post }: { post: OriginPost }) { + return ( + + {post.imageUrl ? {`Source : null} + + Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'} + {post.title} + Open original post + + + ); +} + +export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) { + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const closeRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + closeRef.current?.focus(); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('keydown', closeOnEscape); + return () => document.removeEventListener('keydown', closeOnEscape); + }, [isOpen]); + + function close() { + setIsOpen(false); + queueMicrotask(() => triggerRef.current?.focus()); + } + + return ( + <> + + {isOpen ? ( +
{ if (event.target === event.currentTarget) close(); }} + role="dialog" + > +
+
+ Conversation origin + +
+ {post.imageUrl ? {`Source : null} +
+ {post.subreddit ? r/{post.subreddit} : null} +

{post.title}

+ Open Reddit post +
+
+
+ ) : null} + + ); +} diff --git a/apps/dashboard/src/components/reddit-user-text.tsx b/apps/dashboard/src/components/reddit-user-text.tsx new file mode 100644 index 0000000..42a25c2 --- /dev/null +++ b/apps/dashboard/src/components/reddit-user-text.tsx @@ -0,0 +1,16 @@ +import { Fragment } from 'react'; +import { RedditUsernameLink } from './reddit-username-link.js'; + +const USER_MENTION = /(u\/[A-Za-z0-9_-]+|@?t2_[A-Za-z0-9]+(?::reddit\.com)?)/gi; + +export function RedditUserText({ children }: { children: string }) { + return children.split(USER_MENTION).map((part, index) => { + if (/^u\/(?!t2_)[A-Za-z0-9_-]+$/i.test(part)) { + return ; + } + if (/^(?:@?t2_)/i.test(part) || /^u\/t2_/i.test(part)) { + return Reddit user; + } + return {part}; + }); +} diff --git a/apps/dashboard/src/components/reddit-username-link.tsx b/apps/dashboard/src/components/reddit-username-link.tsx new file mode 100644 index 0000000..85584b3 --- /dev/null +++ b/apps/dashboard/src/components/reddit-username-link.tsx @@ -0,0 +1,42 @@ +export interface RedditUsernameLinkProps { + username: string; + showPrefix?: boolean; +} + +export function normalizeRedditUsername(username: string): string { + return username.trim().replace(/^\/?u\//i, ''); +} + +export function isDisplayableRedditUsername(username: string): boolean { + const normalized = normalizeRedditUsername(username); + return normalized.length > 0 + && !/^(?:me|you|unknown|unknown reddit user)$/i.test(normalized) + && !/^t2_/i.test(normalized) + && !/^@t2_/i.test(normalized); +} + +export function redditUsernameLabel(username: string, showPrefix = true): string { + if (!isDisplayableRedditUsername(username)) return 'Unknown Reddit user'; + const normalized = normalizeRedditUsername(username); + return showPrefix ? `u/${normalized}` : normalized; +} + +export function RedditUsernameLink({ username, showPrefix = true }: RedditUsernameLinkProps) { + const normalizedUsername = normalizeRedditUsername(username); + const label = redditUsernameLabel(username, showPrefix); + + if (!isDisplayableRedditUsername(username)) { + return {label}; + } + + return ( + + {label} + + ); +} diff --git a/apps/dashboard/src/features/conversations/conversation-api.ts b/apps/dashboard/src/features/conversations/conversation-api.ts index 741b383..05027dc 100644 --- a/apps/dashboard/src/features/conversations/conversation-api.ts +++ b/apps/dashboard/src/features/conversations/conversation-api.ts @@ -44,7 +44,10 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve return { async list() { - return parseResponse(await request('/api/conversations'), ConversationsResponseSchema); + return parseResponse( + await request('/api/conversations?includeImageMetadata=true'), + ConversationsResponseSchema, + ); }, async detail(id) { return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema); diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index d573153..b172991 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -1,5 +1,7 @@ import { useCallback, useRef, useState } from 'react'; import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js'; +import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js'; +import { OriginPostCard } from '../../components/origin-post.js'; import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js'; import { SendConfirmationDialog } from './send-confirmation-dialog.js'; @@ -12,6 +14,10 @@ function formatMessageTime(timestamp: number): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); } +function mediaPath(localPath: string): string { + return `/media/${localPath.split('/').map(encodeURIComponent).join('/')}`; +} + function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) { if (!conversation.messages || conversation.messages.length === 0) { return
No messages have been loaded for this conversation.
; @@ -22,10 +28,22 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp {conversation.messages.map((message) => (
  • - {message.isSelf ? 'You' : `u/${conversation.username}`} + {message.isSelf ? 'You' : }

    {message.text}

    + {message.media?.length ? ( +
    + {message.media.map((media) => ( + {`Image + ))} +
    + ) : null}
  • ))} @@ -79,7 +97,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta {mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'} - +